diff --git a/.env.example b/.env.example index 825b563d..a019c077 100644 --- a/.env.example +++ b/.env.example @@ -96,6 +96,10 @@ WORKFLOW_PROVIDER_CONTROL_TOKEN= CLOCKWORK_CRM_ENABLED=false CRM_PROVIDER_BASE_URL= CRM_PROVIDER_TOKEN= +# HubSpot handoff and pipeline (docs/operations/hubspot-handoff.md): a private +# app token with read-only CRM scopes. Unset leaves HubSpot unavailable; even +# when set, nothing is read until an administrator approves the connection. +HUBSPOT_ACCESS_TOKEN= ACCOUNTING_PROVIDER_BASE_URL= ACCOUNTING_PROVIDER_TOKEN= NOTIFICATION_PROVIDER_BASE_URL= diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml index a1a7e3d7..ec714d47 100644 --- a/.github/workflows/terraform.yml +++ b/.github/workflows/terraform.yml @@ -77,6 +77,7 @@ jobs: TF_VAR_staff_provisioning: ${{ secrets.CLOCKWORK_STAFF_PROVISIONING }} TF_VAR_signwell_api_key: ${{ secrets.SIGNWELL_API_KEY }} TF_VAR_signwell_webhook_id: ${{ secrets.SIGNWELL_WEBHOOK_ID }} + TF_VAR_hubspot_access_token: ${{ secrets.HUBSPOT_ACCESS_TOKEN }} TF_VAR_commerce_mnda_enabled: ${{ vars.COMMERCE_MNDA_ENABLED || 'false' }} TF_VAR_commerce_contracts_signing_enabled: ${{ vars.COMMERCE_CONTRACTS_SIGNING_ENABLED || 'true' }} diff --git a/apps/web/app/(experience)/(internal)/internal/hubspot/page.tsx b/apps/web/app/(experience)/(internal)/internal/hubspot/page.tsx new file mode 100644 index 00000000..a516361e --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/hubspot/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; + +import { handoffHref } from "@/src/features/internal-ops/hubspot/model"; +import { loadPicker } from "@/src/features/internal-ops/hubspot/page-data"; +import { HubSpotPageState } from "@/src/features/internal-ops/hubspot/page-state"; +import { HubSpotPicker } from "@/src/features/internal-ops/hubspot/picker"; +import { withStaffPermission } from "@/src/features/shell/staff-access"; +import { getTranslations } from "@/src/i18n/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { title: t("operations.hubspot.picker.title") }; +} + +/** Choose a HubSpot company or deal to bring in: `sales:read`. */ +async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + const [t, loaded] = await Promise.all([ + getTranslations(), + searchParams.then(loadPicker), + ]); + if (loaded.kind !== "ready") + return ( + + ); + // A pasted record link opens its review directly. + if (loaded.result.kind === "record") + redirect( + loaded.result.objectType === "company" + ? handoffHref({ + company: loaded.result.objectId, + deal: loaded.deal, + partner: loaded.partner, + }) + : handoffHref({ + deal: loaded.result.objectId, + partner: loaded.partner, + }), + ); + return ( + + ); +} + +export default withStaffPermission("sales:read", Page); diff --git a/apps/web/app/(experience)/(internal)/internal/hubspot/review/page.tsx b/apps/web/app/(experience)/(internal)/internal/hubspot/review/page.tsx new file mode 100644 index 00000000..bbfd8335 --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/hubspot/review/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; + +import { loadReview } from "@/src/features/internal-ops/hubspot/page-data"; +import { HubSpotPageState } from "@/src/features/internal-ops/hubspot/page-state"; +import { ReviewPage } from "@/src/features/internal-ops/hubspot/review-page"; +import { withStaffPermission } from "@/src/features/shell/staff-access"; +import { getTranslations } from "@/src/i18n/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { title: t("operations.hubspot.review.title") }; +} + +/** + * The handoff review. Reading it needs `sales:read`; committing it is the + * server action's own `contract:write` check. + */ +async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + const [t, loaded] = await Promise.all([ + getTranslations(), + searchParams.then(loadReview), + ]); + if (loaded.kind !== "ready") + return ( + + ); + return ; +} + +export default withStaffPermission("sales:read", Page); diff --git a/apps/web/app/(experience)/(internal)/internal/mndas/page.tsx b/apps/web/app/(experience)/(internal)/internal/mndas/page.tsx index 4a51b659..15a46b80 100644 --- a/apps/web/app/(experience)/(internal)/internal/mndas/page.tsx +++ b/apps/web/app/(experience)/(internal)/internal/mndas/page.tsx @@ -4,6 +4,7 @@ import { getTranslations } from "@/src/i18n/server"; import { loadMndaPage } from "@/src/features/internal-ops/mnda/page-data"; import { MndaAccessState } from "@/src/features/internal-ops/mnda/access-state"; import { MndaWorkspace } from "@/src/features/internal-ops/mnda/workspace"; +import { loadMndaPrefill } from "@/src/features/internal-ops/hubspot/page-data"; export const dynamic = "force-dynamic"; export async function generateMetadata(): Promise { @@ -21,11 +22,18 @@ export default async function Page({ const query = parseMndaRegisterParams(params); const result = await loadMndaPage(query); if (!result.ok) return ; + const compose = params.compose === "1"; + // `handoff` names a HubSpot import whose company and chosen contact are + // offered as suggestions; the page has already checked MNDA access. + const prefill = compose + ? await loadMndaPrefill(params.partner, params.handoff) + : null; return ( ); } diff --git a/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx b/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx new file mode 100644 index 00000000..6dd547fe --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/owner/hubspot/page.tsx @@ -0,0 +1,79 @@ +import type { Metadata, Route } from "next"; +import Link from "next/link"; + +import { Breadcrumbs, PageHeader } from "@clockwork/ui"; + +import { loadSettings } from "@/src/features/internal-ops/hubspot/page-data"; +import { HubSpotPageState } from "@/src/features/internal-ops/hubspot/page-state"; +import { HubSpotSettings } from "@/src/features/internal-ops/hubspot/settings"; +import { + HubSpotSetupGuide, + hubspotSetupStage, +} from "@/src/features/internal-ops/hubspot/setup-guide"; +import styles from "@/src/features/internal-ops/hubspot/hubspot.module.css"; +import { breadcrumbsLabel } from "@/src/features/shared/ui-kit-labels"; +import { withStaffPermission } from "@/src/features/shell/staff-access"; +import { getTranslations } from "@/src/i18n/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { title: t("operations.hubspot.settings.title") }; +} + +/** The HubSpot connection, its audience and its scope: `staff:manage`. */ +async function Page() { + const [t, loaded] = await Promise.all([getTranslations(), loadSettings()]); + if (loaded.kind !== "ready") + return ( + + ); + return ( +
+ {label}} + /> + + {loaded.demo ? null : ( + + )} + +
+ ); +} + +export default withStaffPermission("staff:manage", Page); diff --git a/apps/web/app/(experience)/(internal)/internal/partners/[id]/page.tsx b/apps/web/app/(experience)/(internal)/internal/partners/[id]/page.tsx index d26325db..f0aaea43 100644 --- a/apps/web/app/(experience)/(internal)/internal/partners/[id]/page.tsx +++ b/apps/web/app/(experience)/(internal)/internal/partners/[id]/page.tsx @@ -2,6 +2,9 @@ import { AccountDocumentsSection } from "@/src/features/internal-ops/account-doc import type { Metadata } from "next"; import { cache } from "react"; +import { loadPartnerHubSpotParts } from "@/src/features/internal-ops/hubspot/page-data"; +import { PartnerHubSpotSection } from "@/src/features/internal-ops/hubspot/partner-section"; +import { HubSpotReceipt } from "@/src/features/internal-ops/hubspot/receipt"; import { PartnerDetail } from "@/src/features/internal-ops/partners/partner-detail"; import { PartnerPageState } from "@/src/features/internal-ops/partners/page-state"; import { loadPartner } from "@/src/features/internal-ops/partners/server"; @@ -28,8 +31,14 @@ export async function generateMetadata({ }; } -async function Page({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; +async function Page({ + params, + searchParams, +}: { + params: Promise<{ id: string }>; + searchParams: Promise>; +}) { + const [{ id }, query] = await Promise.all([params, searchParams]); const [t, locale, loaded] = await Promise.all([ getTranslations(), getFormattingLocale(), @@ -42,6 +51,7 @@ async function Page({ params }: { params: Promise<{ id: string }> }) { title={t("operations.partners.detail.title")} /> ); + const hubspot = await loadPartnerHubSpotParts(loaded.partner, query); return ( }) { target={{ type: "partner", id: loaded.partner.id }} /> } + hubspot={ + + } + receipt={ + hubspot.receipt ? ( + + ) : null + } + dealSuggestion={loaded.canEdit ? hubspot.dealSuggestion : null} partner={loaded.partner} deals={loaded.deals} activity={loaded.activity} diff --git a/apps/web/app/(experience)/(internal)/internal/partners/page.tsx b/apps/web/app/(experience)/(internal)/internal/partners/page.tsx index ff8fbc9d..24c00033 100644 --- a/apps/web/app/(experience)/(internal)/internal/partners/page.tsx +++ b/apps/web/app/(experience)/(internal)/internal/partners/page.tsx @@ -38,6 +38,7 @@ async function Page({ today={loaded.today} canEdit={loaded.canEdit} demo={loaded.demo} + hubspot={loaded.hubspot} /> ); } diff --git a/apps/web/app/(experience)/(internal)/internal/pipeline/page.tsx b/apps/web/app/(experience)/(internal)/internal/pipeline/page.tsx new file mode 100644 index 00000000..195ed031 --- /dev/null +++ b/apps/web/app/(experience)/(internal)/internal/pipeline/page.tsx @@ -0,0 +1,48 @@ +import type { Metadata } from "next"; + +import { loadPipelinePage } from "@/src/features/internal-ops/hubspot/page-data"; +import { HubSpotPageState } from "@/src/features/internal-ops/hubspot/page-state"; +import { PipelinePage } from "@/src/features/internal-ops/hubspot/pipeline"; +import { withStaffPermission } from "@/src/features/shell/staff-access"; +import { getFormattingLocale, getTranslations } from "@/src/i18n/server"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { title: t("operations.hubspot.pipeline.title") }; +} + +/** HubSpot's pipeline, read-only, for the sales workspace: `sales:read`. */ +async function Page({ + searchParams, +}: { + searchParams: Promise>; +}) { + const [t, locale, loaded] = await Promise.all([ + getTranslations(), + getFormattingLocale(), + searchParams.then(loadPipelinePage), + ]); + if (loaded.kind !== "ready") + return ( + + ); + return ( + + ); +} + +export default withStaffPermission("sales:read", Page); diff --git a/apps/web/app/(experience)/channel-surfaces.test.ts b/apps/web/app/(experience)/channel-surfaces.test.ts index 8e58c7be..cc6c8513 100644 --- a/apps/web/app/(experience)/channel-surfaces.test.ts +++ b/apps/web/app/(experience)/channel-surfaces.test.ts @@ -306,6 +306,10 @@ const expected: Readonly> = { "/internal/gates", // Sales-to-operations handoffs read their own register. "/internal/handoffs", + // HubSpot picker and handoff review read HubSpot through the approved + // connection, never a projection channel. + "/internal/hubspot", + "/internal/hubspot/review", "/internal/migrations", // Standalone pre-sales MNDA repository, not an account projection channel. "/internal/mndas", @@ -321,12 +325,16 @@ const expected: Readonly> = { "/internal/organizations/new", // The owner console reads notices, controls and audit events directly. "/internal/owner", + // The HubSpot connection, read from its own table. + "/internal/owner/hubspot", // Staff partner records and registered deals, read from their own tables. "/internal/partners", "/internal/partners/new", "/internal/payg-offers", // Finance reads verified customer handoff requests directly. "/internal/payg-requests", + // HubSpot's pipeline, read live through the approved connection. + "/internal/pipeline", "/internal/price-books", // Indicative pricing reads the price book registry, as price-books does. "/internal/pricing", diff --git a/apps/web/src/features/internal-ops/hubspot/actions.ts b/apps/web/src/features/internal-ops/hubspot/actions.ts new file mode 100644 index 00000000..b4c7ae48 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/actions.ts @@ -0,0 +1,412 @@ +"use server"; + +import { createHash, randomUUID } from "node:crypto"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { + HubSpotHandoffInputSchema, + hubspotLimits, + withPartnerContactIds, + type HubSpotImportRecord, +} from "@clockwork/contracts"; +import { HubSpotError } from "@clockwork/integrations/hubspot"; + +import { SessionExpiredError } from "@/src/auth/session"; + +import { accountDocumentRepository } from "../account-documents/server"; +import { attempt, type ActionResult } from "../contracts/action-result"; +import { contractActor, contractStaff } from "../contracts/server"; +import { partnerRepository, partnerToday } from "../partners/server"; +import { buildHubSpotPlan, changedReviewItems, HandoffPlanError } from "./plan"; +import { + clearHubSpotCache, + configuredPortal, + hubspotContext, + hubspotRepository, + HubSpotUnavailableError, + loadHandoffSource, + requestDigest, + similarPartnersFor, +} from "./server"; + +async function run(operation: () => Promise): Promise> { + return attempt(async () => { + try { + return await operation(); + } catch (error) { + if (error instanceof SessionExpiredError) + throw new Error("SESSION_EXPIRED"); + if ( + error instanceof HubSpotUnavailableError || + error instanceof HandoffPlanError + ) + throw new Error(error.code); + throw error; + } + }); +} + +export type HandoffCommitResult = + | { + kind: "committed"; + partnerId: string; + importId: string; + pendingDocuments: number; + } + | { kind: "already_linked"; partnerId: string; partnerName: string } + | { kind: "source_changed"; items: string[] }; + +/** + * Commits a reviewed handoff. The source is read again now: anything + * selected that changed since the review sends the person back to it, and + * nothing the browser sent is trusted as a source value. A retry of the same + * operation, even after its answer was lost, resolves to the first result. + */ +export async function commitHubSpotHandoff(raw: unknown) { + return run(async (): Promise => { + const session = await contractStaff("contract:write"); + const actor = contractActor(session); + const handoff = HubSpotHandoffInputSchema.parse(raw); + const digest = requestDigest(raw); + const repository = hubspotRepository(); + const prior = await repository.operation(handoff.operationId); + if (prior) { + if (prior.requestDigest !== digest || prior.createdById !== actor.id) + throw new Error("HUBSPOT_OPERATION_CONFLICT"); + const record = await repository.import( + prior.partnerId, + handoff.operationId, + ); + return { + kind: "committed", + partnerId: prior.partnerId, + importId: handoff.operationId, + pendingDocuments: record.items.filter((i) => i.status === "pending") + .length, + }; + } + const context = await hubspotContext(); + if ( + context.connection.id !== handoff.connection.id || + context.connection.version !== handoff.connection.version + ) + throw new Error("HUBSPOT_CONNECTION_CHANGED"); + const source = await loadHandoffSource(context, handoff.source); + const changed = changedReviewItems( + handoff.reviewed, + source.digests, + handoff, + ); + if (changed.length) return { kind: "source_changed", items: changed }; + if (handoff.destination.kind === "new_partner") { + const dismissed = new Set( + handoff.destination.dismissedSuggestions.map((item) => item.partnerId), + ); + const similar = await similarPartnersFor(repository, source); + if (similar.some((partner) => !dismissed.has(partner.id))) + throw new Error("HUBSPOT_SIMILAR_PARTNER"); + } + const partners = partnerRepository(); + const found = + handoff.destination.kind === "existing_partner" + ? (await partners.get(handoff.destination.partnerId, partnerToday())) + .partner + : null; + // A partner edited since the review is a version conflict before + // anything else, so the page refreshes the comparison. + if ( + found && + handoff.destination.kind === "existing_partner" && + found.version !== handoff.destination.expectedVersion + ) + throw new Error("PARTNER_VERSION_CONFLICT"); + // The same contact IDs the review used, including derived ones. + const destination = found + ? { ...found, contacts: withPartnerContactIds(found.id, found.contacts) } + : null; + const [history, owners] = await Promise.all([ + destination + ? repository.fieldHistory(destination.id) + : Promise.resolve({}), + handoff.destination.kind === "new_partner" && handoff.destination.ownerId + ? partners.owners() + : Promise.resolve([]), + ]); + const ownerId = + handoff.destination.kind === "new_partner" + ? handoff.destination.ownerId + : null; + const { plan, snapshot } = buildHubSpotPlan({ + source, + handoff, + destination, + history, + ownerName: owners.find((owner) => owner.id === ownerId)?.name ?? null, + newId: randomUUID, + }); + const result = await repository.commit( + { + plan, + snapshot, + requestDigest: digest, + connection: handoff.connection, + companyId: handoff.source.companyId, + dealId: handoff.source.dealId, + }, + actor, + ); + revalidatePath("/internal/partners"); + revalidatePath("/internal/pipeline"); + if (result.kind === "already_linked") return result; + revalidatePath(`/internal/partners/${result.partnerId}`); + return { + kind: "committed", + partnerId: result.partnerId, + importId: result.importId, + pendingDocuments: plan.documents.length, + }; + }); +} + +const ItemSchema = z + .object({ partnerId: z.guid(), importId: z.guid(), itemId: z.guid() }) + .strict(); + +const sha256 = (bytes: Uint8Array) => + createHash("sha256").update(bytes).digest("hex"); + +/** + * Brings one selected document across: re-reads the file's current version, + * refuses one that changed since the review, keeps the original bytes as a + * private attachment with HubSpot provenance, and stops at an identical PDF + * already on the partner so a person chooses what happens. Each attempt is + * recorded; a retry never repeats anything already done. + */ +export async function processHubSpotDocument(raw: unknown) { + return run(async (): Promise => { + const session = await contractStaff("contract:write"); + const actor = contractActor(session); + const { partnerId, importId, itemId } = ItemSchema.parse(raw); + const repository = hubspotRepository(); + const record = await repository.import(partnerId, importId); + const item = record.items.find((candidate) => candidate.id === itemId); + if (!item) throw new Error("HUBSPOT_ITEM_NOT_FOUND"); + if (!["pending", "failed"].includes(item.status)) + throw new Error("HUBSPOT_ITEM_RESOLVED"); + // A previous attempt attached it but its answer was lost. + const already = await repository.attachmentOnPartner(partnerId, item.id); + if (already) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "attached", attachmentId: already.id }, + actor, + ); + const fail = (errorCode: string) => + repository.recordItem( + partnerId, + importId, + item.id, + { status: "failed", errorCode }, + actor, + ); + let bytes: Uint8Array; + try { + const context = await hubspotContext(); + if (context.connection.portalId !== record.portalId) + return await fail("HUBSPOT_PORTAL_MISMATCH"); + if (!context.connection.scope.files) + return await fail("HUBSPOT_SCOPE_DENIED"); + const file = await context.reader.file(item.fileId, item.noteId); + if (!file || file.availability !== "available") + return await fail("HUBSPOT_DOCUMENT_UNAVAILABLE"); + const reviewed = record.plan.documents.find( + (document) => document.fileId === item.fileId, + ); + // A file replaced in HubSpot since the review needs a fresh review. + if ( + reviewed && + ((reviewed.updatedAt && file.updatedAt !== reviewed.updatedAt) || + (reviewed.size !== null && file.size !== reviewed.size)) + ) + return await fail("HUBSPOT_DOCUMENT_CHANGED"); + bytes = await context.reader.download( + item.fileId, + hubspotLimits.documentBytes, + ); + } catch (error) { + if (error instanceof HubSpotUnavailableError) return fail(error.code); + if (error instanceof HubSpotError) return fail(error.code); + throw error; + } + const hash = sha256(bytes); + const duplicate = await repository.attachmentByHash(partnerId, hash); + // Another attempt (a second tab) attached this very item meanwhile. + if (duplicate?.id === item.id) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "attached", attachmentId: item.id }, + actor, + ); + if (duplicate) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "duplicate", existingAttachmentId: duplicate.id }, + actor, + ); + try { + const attached = await accountDocumentRepository().add( + { type: "partner", id: partnerId }, + { id: item.id, title: item.title, kind: item.documentKind }, + { fileName: item.fileName, bytes }, + actor, + { + provider: "hubspot", + portalId: record.portalId, + fileId: item.fileId, + noteId: item.noteId, + importId, + }, + ); + revalidatePath(`/internal/partners/${partnerId}`); + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "attached", attachmentId: attached.id }, + actor, + ); + } catch (error) { + const code = + error instanceof z.ZodError + ? "DOCUMENT_INVALID" + : error instanceof Error + ? error.message + : ""; + if (code === "ATTACHMENT_ALREADY_EXISTS") { + const existing = await repository.attachmentByHash(partnerId, hash); + if (existing) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "duplicate", existingAttachmentId: existing.id }, + actor, + ); + } + if (code === "ATTACHMENT_RETRY_CONFLICT") { + const mine = await repository.attachmentOnPartner(partnerId, item.id); + if (mine) + return repository.recordItem( + partnerId, + importId, + item.id, + { status: "attached", attachmentId: mine.id }, + actor, + ); + } + return fail( + /^(DOCUMENT_|ATTACHMENT_)/.test(code) + ? "HUBSPOT_DOCUMENT_INVALID" + : "HUBSPOT_DOCUMENT_UNAVAILABLE", + ); + } + }); +} + +/** Uses the identical PDF already on the partner for a duplicate document. */ +export async function reuseHubSpotDocument(raw: unknown) { + return run(async () => { + const session = await contractStaff("contract:write"); + const { partnerId, importId, itemId } = ItemSchema.parse(raw); + const record = await hubspotRepository().reuseExisting( + partnerId, + importId, + itemId, + contractActor(session), + ); + revalidatePath(`/internal/partners/${partnerId}`); + return record; + }); +} + +/** + * Leaves a document out and finishes the handoff without it. The receipt + * then says "Completed with 1 item excluded", never a clean success. + */ +export async function excludeHubSpotDocument(raw: unknown) { + return run(async () => { + const session = await contractStaff("contract:write"); + const input = ItemSchema.extend({ + reason: z.string().trim().min(1).max(500), + }) + .strict() + .parse(raw); + const record = await hubspotRepository().excludeItem( + input.partnerId, + input.importId, + input.itemId, + input.reason, + contractActor(session), + ); + revalidatePath(`/internal/partners/${input.partnerId}`); + return record; + }); +} + +/** Reads HubSpot again for every page, without importing anything. */ +export async function refreshHubSpotData() { + return run(async () => { + await contractStaff("sales:read"); + clearHubSpotCache(); + revalidatePath("/internal/pipeline"); + return null; + }); +} + +/** + * Activates the connection, or changes its scope. The token's portal is + * read now and must be the one the administrator approved on the page. + */ +export async function saveHubSpotConnection(raw: unknown) { + return run(async () => { + const session = await contractStaff("staff:manage"); + const portal = await configuredPortal(); + if (!portal) throw new Error("HUBSPOT_NOT_CONFIGURED"); + const connection = await hubspotRepository().saveConnection( + raw, + portal, + contractActor(session), + ); + clearHubSpotCache(); + revalidatePath("/internal/owner/hubspot"); + return { id: connection.id, version: connection.version }; + }); +} + +/** + * Stops live HubSpot reads. Imported records, documents and history stay; + * pending reviews can no longer be committed. + */ +export async function disconnectHubSpot(raw: unknown) { + return run(async () => { + const session = await contractStaff("staff:manage"); + const { expectedVersion } = z + .object({ expectedVersion: z.int().min(1) }) + .strict() + .parse(raw); + await hubspotRepository().disconnect( + expectedVersion, + contractActor(session), + ); + clearHubSpotCache(); + revalidatePath("/internal/owner/hubspot"); + return null; + }); +} diff --git a/apps/web/src/features/internal-ops/hubspot/hubspot.module.css b/apps/web/src/features/internal-ops/hubspot/hubspot.module.css new file mode 100644 index 00000000..95cd9a95 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/hubspot.module.css @@ -0,0 +1,549 @@ +/* HubSpot handoff, pipeline and settings. Orange only marks where a value + came from; it never signals validity or state. */ +.main { + display: grid; + width: min(100%, 1240px); + min-width: 0; + gap: var(--cw-space-5); + margin: 0 auto; + padding: clamp(1.25rem, 3vw, 2.5rem); + color: var(--cw-ink); +} + +.main > * { + min-width: 0; +} + +.main :global(.cw-page-header) { + margin-bottom: 0; +} + +.muted { + max-width: 72ch; + margin: 0; + color: var(--cw-muted); + font-size: var(--cw-text-md); + line-height: 1.5; +} + +.secondary { + color: var(--cw-muted); + font-size: var(--cw-text-sm); + overflow-wrap: anywhere; +} + +.prose { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.card { + display: grid; + min-width: 0; + gap: var(--cw-space-4); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-lg); + background: var(--cw-surface); + box-shadow: var(--cw-shadow-xs); + padding: var(--cw-space-5); +} + +.card > h2, +.card > h3, +.cardHeading h2 { + margin: 0; + font-size: var(--cw-text-lg); + font-weight: 600; +} + +.cardHeading { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--cw-space-3); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--cw-space-2); +} + +.source { + display: inline-flex; + align-items: center; + gap: 0.4em; + color: var(--cw-muted); + font-size: var(--cw-text-sm); +} + +.source::before { + content: ""; + width: 0.5em; + height: 0.5em; + flex: none; + border-radius: 50%; + background: #e8613c; +} + +.facts { + display: grid; + grid-template-columns: minmax(9rem, max-content) minmax(0, 1fr); + gap: var(--cw-space-2) var(--cw-space-4); + margin: 0; +} + +.facts dt { + color: var(--cw-muted); + font-weight: 600; +} + +.facts dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.list { + display: grid; + gap: var(--cw-space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.list > li { + display: grid; + gap: var(--cw-space-1); + padding-bottom: var(--cw-space-3); + border-bottom: 1px solid var(--cw-border); +} + +.list > li:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.rowHeading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--cw-space-2); +} + +.rowHeading h3 { + margin: 0; + font-size: var(--cw-text-md); + font-weight: 600; + overflow-wrap: anywhere; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: var(--cw-space-1); + margin: 0; + padding: 0; + list-style: none; +} + +.rowLink { + color: var(--cw-ink); + font-weight: 500; + text-decoration: none; +} + +.rowLink:hover, +.rowLink:focus-visible { + color: var(--cw-action-strong); + text-decoration: underline; +} + +/* Search */ +.search { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: var(--cw-space-3); +} + +.results { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr)); + gap: var(--cw-space-5); + align-items: start; +} + +/* Review: source and destination stay in view while choosing. */ +.handoff { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: stretch; + gap: var(--cw-space-3); +} + +.endpoint { + display: grid; + align-content: start; + gap: var(--cw-space-1); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-lg); + background: var(--cw-surface); + padding: var(--cw-space-4); +} + +.endpoint strong { + font-size: var(--cw-text-lg); + overflow-wrap: anywhere; +} + +.arrow { + align-self: center; + color: var(--cw-muted); + font-size: var(--cw-text-lg); +} + +.form { + display: grid; + gap: var(--cw-space-5); + /* Room for the sticky footer so it never covers the last field. */ + padding-bottom: var(--cw-space-4); +} + +.fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr)); + gap: var(--cw-space-3) var(--cw-space-4); +} + +.wide { + grid-column: 1 / -1; +} + +.choices { + display: grid; + gap: var(--cw-space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.choice { + display: grid; + gap: var(--cw-space-2); + padding: var(--cw-space-3); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-sm); +} + +.choice[data-selected] { + border-color: var(--cw-action); + background: var(--cw-action-soft); +} + +.choiceDetail { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr)); + gap: var(--cw-space-2) var(--cw-space-3); + padding-left: 1.75rem; +} + +.unavailable { + color: var(--cw-muted); +} + +.compare { + width: 100%; + border-collapse: collapse; +} + +.compare th, +.compare td { + padding: var(--cw-space-2) var(--cw-space-3); + border-bottom: 1px solid var(--cw-border); + text-align: left; + vertical-align: top; + overflow-wrap: anywhere; +} + +.compare thead th { + color: var(--cw-muted); + font-size: var(--cw-text-sm); + font-weight: 600; +} + +.decision { + display: flex; + flex-wrap: wrap; + gap: var(--cw-space-2) var(--cw-space-3); + margin: 0; + padding: 0; + border: 0; +} + +.decision label { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.more { + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-lg); + background: var(--cw-surface); +} + +.more > summary { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--cw-space-2) var(--cw-space-3); + padding: var(--cw-space-4) var(--cw-space-5); + cursor: pointer; + font-weight: 600; +} + +.more > summary span { + color: var(--cw-muted); + font-size: var(--cw-text-sm); + font-weight: 400; +} + +.moreBody { + display: grid; + gap: var(--cw-space-5); + padding: 0 var(--cw-space-5) var(--cw-space-5); +} + +.footer { + position: sticky; + bottom: 0; + z-index: 2; + display: grid; + gap: var(--cw-space-3); + border: 1px solid var(--cw-border-strong); + border-radius: var(--cw-radius-lg); + background: var(--cw-surface-raised, var(--cw-surface)); + box-shadow: var(--cw-shadow-popover); + padding: var(--cw-space-4) var(--cw-space-5); +} + +/* Focused fields scroll clear of the sticky footer, and a short or zoomed + window gets the footer back in the flow instead of covering the form. */ +:global(html):has(.footer) { + scroll-padding-bottom: 12rem; +} + +@media (max-height: 34rem) { + .footer { + position: static; + } +} + +.footerRow { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--cw-space-3); +} + +.summary { + margin: 0; + font-weight: 600; +} + +/* Pipeline */ +.filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr)); + align-items: end; + gap: var(--cw-space-3); +} + +.stages { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr)); + gap: var(--cw-space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.stage { + display: grid; + gap: var(--cw-space-1); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-sm); + background: var(--cw-surface); + padding: var(--cw-space-3); +} + +.stage strong { + font-size: var(--cw-text-lg); +} + +.stageBar { + height: 0.35rem; + border-radius: 999px; + background: var(--cw-action-soft); + overflow: hidden; +} + +.stageBar span { + display: block; + height: 100%; + background: var(--cw-action); +} + +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: var(--cw-space-5); + align-items: start; +} + +.workspace[data-panel] { + grid-template-columns: minmax(0, 1fr) minmax(20rem, 26rem); +} + +.panel { + position: sticky; + top: calc(var(--cw-header-height, 4rem) + var(--cw-space-4)); +} + +.board { + display: grid; + grid-auto-columns: minmax(15rem, 1fr); + grid-auto-flow: column; + gap: var(--cw-space-3); + overflow-x: auto; + padding-bottom: var(--cw-space-2); +} + +.column { + display: grid; + align-content: start; + gap: var(--cw-space-2); + margin: 0; + padding: var(--cw-space-3); + border-radius: var(--cw-radius-lg); + background: var(--cw-canvas-deep, var(--cw-canvas)); + list-style: none; +} + +.column h3 { + margin: 0 0 var(--cw-space-1); + font-size: var(--cw-text-md); +} + +.dealCard { + display: grid; + gap: var(--cw-space-1); + border: 1px solid var(--cw-border); + border-radius: var(--cw-radius-sm); + background: var(--cw-surface); + padding: var(--cw-space-3); +} + +.scopeGroup { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr)); + gap: var(--cw-space-2) var(--cw-space-4); +} + +.receipt:focus { + outline: none; +} + +.receipt:focus-visible { + outline: 2px solid var(--cw-focus); + outline-offset: 4px; +} + +@media (max-width: 64rem) { + .workspace[data-panel] { + grid-template-columns: minmax(0, 1fr); + } + + .panel { + position: static; + order: -1; + } +} + +@media (max-width: 40rem) { + .handoff { + grid-template-columns: minmax(0, 1fr); + } + + .arrow { + justify-self: center; + transform: rotate(90deg); + } + + .search { + grid-template-columns: minmax(0, 1fr); + } + + .card { + padding: var(--cw-space-4); + } + + .facts { + grid-template-columns: minmax(0, 1fr); + } + + .facts dd { + margin-bottom: var(--cw-space-2); + } + + .choiceDetail { + padding-left: 0; + } + + /* Comparisons become labeled Commerce / HubSpot / Result rows. */ + .compare thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + } + + .compare tr, + .compare td, + .compare th { + display: block; + } + + .compare tr { + padding: var(--cw-space-2) 0; + border-bottom: 1px solid var(--cw-border); + } + + .compare td, + .compare th { + border: 0; + padding: var(--cw-space-1) 0; + } + + .compare td[data-label]::before { + content: attr(data-label) ": "; + color: var(--cw-muted); + font-weight: 600; + } + + .footer { + padding: var(--cw-space-3); + } +} + +@media (prefers-reduced-motion: reduce) { + .stageBar span { + transition: none; + } +} diff --git a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx new file mode 100644 index 00000000..4ecf6ed7 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx @@ -0,0 +1,868 @@ +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + HubSpotHandoffInputSchema, + HubSpotPipelineQuerySchema, + PartnerTermsSchema, + withPartnerContactIds, + type HubSpotConnectionRecord, + type HubSpotImportRecord, + type PartnerRecord, +} from "@clockwork/contracts"; +import { FakeHubSpotReader } from "@clockwork/integrations/hubspot"; + +const mocks = vi.hoisted(() => ({ + commit: vi.fn(), + process: vi.fn(), + exclude: vi.fn(), + reuse: vi.fn(), + push: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), +})); +vi.mock("./actions", () => ({ + commitHubSpotHandoff: mocks.commit, + processHubSpotDocument: mocks.process, + excludeHubSpotDocument: mocks.exclude, + reuseHubSpotDocument: mocks.reuse, +})); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: mocks.push, + refresh: mocks.refresh, + back: mocks.back, + }), +})); +vi.mock("@/src/auth/session", () => ({ + explicitDemoIdentityEnabled: () => false, +})); +vi.mock("@/src/db/service", () => ({ + getOptionalServiceDatabase: () => undefined, +})); + +import { translatorFor } from "@/src/i18n/catalogs"; + +import { buildHubSpotPlan, changedReviewItems } from "./plan"; +import { HubSpotSetupGuide, hubspotSetupStage } from "./setup-guide"; +import { HubSpotReceipt } from "./receipt"; +import { HandoffReview } from "./review"; +import { + loadHandoffSource, + loadPipeline, + searchHubSpot, + type HubSpotContext, + type HubSpotStore, +} from "./server"; + +const now = new Date("2026-10-22T15:00:00.000Z"); +const partnerId = "019a44ae-0000-7000-8000-00000000e001"; +const operationId = "019a44ae-0000-7000-8000-00000000e002"; + +const connection = ( + scope: Partial = {}, +): HubSpotConnectionRecord => ({ + id: "019a44ae-0000-7000-8000-00000000e003", + portalId: "4410", + uiDomain: "app.hubspot.com", + status: "active", + audience: "staff_sales_read", + approvedById: partnerId, + approvedByName: "Admin", + approvedAt: now.toISOString(), + scope: { + company: ["industry", "partner_tier", "tax_id"], + deal: ["deal_source"], + contact: [], + pipelines: ["default", "renewals"], + notes: true, + files: true, + ...scope, + }, + defaultPipelineId: "default", + ownerMappings: [], + updatedAt: now.toISOString(), + version: 3, +}); + +const store = (linked: Record = {}) => + ({ + connection: () => Promise.resolve({ active: null, latest: null }), + partnersForCompanies: (_portal: string, ids: readonly string[]) => + Promise.resolve( + new Map( + ids.flatMap((id) => (linked[id] ? [[id, linked[id]] as const] : [])), + ), + ), + linkForPartner: () => Promise.resolve(null), + imports: () => Promise.resolve([]), + import: () => Promise.reject(new Error("HUBSPOT_IMPORT_NOT_FOUND")), + fieldHistory: () => Promise.resolve({}), + similarPartners: () => Promise.resolve([]), + }) as unknown as HubSpotStore; + +async function context( + options: { + scope?: Partial; + linked?: Record; + reader?: FakeHubSpotReader; + } = {}, +): Promise { + const reader = options.reader ?? new FakeHubSpotReader(now); + return { + // A fresh connection ID per context keeps the display cache apart. + connection: { ...connection(options.scope), id: crypto.randomUUID() }, + reader, + portal: await reader.portal(), + store: store(options.linked), + demo: false, + }; +} + +const partner = (patch: Partial = {}): PartnerRecord => ({ + id: partnerId, + name: "Northstar Systems", + website: "https://northstar.example", + region: "", + models: ["resale"], + status: "signed", + ownerId: null, + ownerName: null, + organizationId: null, + organizationName: null, + contacts: [], + nextStep: "", + nextStepDue: null, + notes: "", + terms: PartnerTermsSchema.parse({}), + createdById: partnerId, + createdByName: "Seller", + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + version: 4, + ...patch, +}); + +describe("reading a handoff source through the approved scope", () => { + it("resolves names and labels and keeps the deal's other companies", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: "7001", + }); + expect(source.proposed).toEqual({ + name: "Northstar Systems", + website: "https://northstar.example", + region: "United States", + }); + expect(source.deal?.reference.associatedCompanies).toEqual([ + { id: "9001", name: "Northstar Systems", labels: ["Reseller"] }, + { + id: "9002", + name: "Halcyon Research Cooperative", + labels: ["Primary", "End customer"], + }, + ]); + expect(source.deal?.reference.stage).toEqual({ + id: "closedwon", + label: "Closed won", + closed: true, + }); + expect(source.contacts.map((c) => [c.name, c.labels])).toEqual([ + ["Ada Okafor", ["Decision maker"]], + ["Marcus Hale", []], + ["Ines Albright", ["Budget holder"]], + ]); + expect(source.notes?.find((n) => n.id === "6002")?.authorName).toBeNull(); + expect(source.files?.map((f) => [f.name, f.availability])).toEqual([ + ["Halcyon order form (signed).pdf", "available"], + ["northstar-logo.png", "unsupported_type"], + ]); + }); + + it("never reads a property HubSpot marks sensitive, even if approved", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + expect(source.companyValues.map((v) => v.property)).toEqual([ + "industry", + "partner_tier", + ]); + }); + + it("leaves notes and files out entirely when they are not approved", async () => { + const reader = new FakeHubSpotReader(now); + const source = await loadHandoffSource( + await context({ scope: { notes: false, files: false }, reader }), + { companyId: "9001", dealId: null }, + ); + expect(source.notes).toBeNull(); + expect(source.files).toBeNull(); + expect(reader.calls.some((call) => call.startsWith("notes:"))).toBe(false); + }); + + it("refuses a company that is not on the deal", async () => { + await expect( + loadHandoffSource(await context(), { companyId: "9004", dealId: "7001" }), + ).rejects.toThrow("HUBSPOT_NOT_ASSOCIATED"); + }); + + it("opens a pasted link from its own portal and explains another portal's", async () => { + const ctx = await context(); + await expect( + searchHubSpot(ctx, "https://app.hubspot.com/contacts/4410/deal/7001"), + ).resolves.toEqual({ + kind: "record", + objectType: "deal", + objectId: "7001", + }); + await expect( + searchHubSpot(ctx, "https://app.hubspot.com/contacts/999/company/1"), + ).resolves.toEqual({ kind: "foreign_portal", portalId: "999" }); + }); +}); + +describe("the handoff plan", () => { + const input = (overrides: Record = {}) => + HubSpotHandoffInputSchema.parse({ + operationId, + connection: { id: partnerId, version: 3 }, + source: { companyId: "9001", dealId: "7001" }, + reviewed: {}, + destination: { + kind: "new_partner", + partnerId, + name: "Northstar Systems", + website: "https://northstar.example", + }, + ...overrides, + }); + + it("creates exactly what was selected and records what was left out", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: "7001", + }); + const { plan, snapshot } = buildHubSpotPlan({ + source, + handoff: input({ + contacts: [{ hubspotId: "5001" }], + primaryContact: { kind: "hubspot", id: "5001" }, + includeDeal: true, + endCustomerCompanyId: "9002", + documents: [{ fileId: "8001", title: "Order form", kind: "agreement" }], + referenceProperties: [{ objectType: "company", name: "partner_tier" }], + }), + destination: null, + history: {}, + ownerName: null, + newId: () => "019a44ae-0000-7000-8000-00000000e009", + }); + expect(plan.fields.map((f) => [f.field, f.action, f.result])).toEqual([ + ["name", "add", "Northstar Systems"], + ["website", "add", "https://northstar.example"], + ["region", "keep", ""], + ]); + expect(plan.primaryContactId).toBe("019a44ae-0000-7000-8000-00000000e009"); + expect(plan.deal?.endCustomer?.name).toBe("Halcyon Research Cooperative"); + expect(plan.deal?.amount).toBe("48000"); + expect(plan.omitted).toEqual({ + contacts: 2, + notes: 2, + documents: 0, + properties: 2, + }); + expect(snapshot.values.map((v) => v.property)).toContain("partner_tier"); + expect(snapshot.company.owner).toEqual({ id: "101", name: "Priya Raman" }); + }); + + it("keeps Commerce values unless a change is chosen and never clears a name", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + const existing = (fields: Record) => + input({ + source: { companyId: "9001", dealId: null }, + destination: { + kind: "existing_partner", + partnerId, + expectedVersion: 4, + fields, + }, + }); + const { plan } = buildHubSpotPlan({ + source, + handoff: existing({ region: "use_hubspot", website: "clear" }), + destination: partner(), + history: {}, + ownerName: null, + newId: () => operationId, + }); + expect(plan.fields.map((f) => [f.field, f.action, f.result])).toEqual([ + ["name", "keep", "Northstar Systems"], + ["website", "clear", ""], + ["region", "add", "United States"], + ]); + expect(() => + buildHubSpotPlan({ + source, + handoff: existing({ name: "clear" }), + destination: partner(), + history: {}, + ownerName: null, + newId: () => operationId, + }), + ).toThrow("HUBSPOT_DECISION_REQUIRED"); + }); + + it("refuses a document HubSpot cannot provide instead of dropping it", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + expect(() => + buildHubSpotPlan({ + source, + handoff: input({ + source: { companyId: "9001", dealId: null }, + documents: [{ fileId: "8002", title: "Logo", kind: "other" }], + }), + destination: null, + history: {}, + ownerName: null, + newId: () => operationId, + }), + ).toThrow("HUBSPOT_DOCUMENT_UNAVAILABLE"); + }); + + it("notices changes to what was selected, and only to that", async () => { + const reader = new FakeHubSpotReader(now); + const ctx = await context({ reader }); + const before = await loadHandoffSource(ctx, { + companyId: "9001", + dealId: null, + }); + reader.updateCompany("9001", { name: "Northstar Systems Inc." }); + const after = await loadHandoffSource(ctx, { + companyId: "9001", + dealId: null, + }); + const selection = { + source: { dealId: null }, + includeDeal: false, + endCustomerCompanyId: null, + contacts: [{ hubspotId: "5001", matchContactId: null }], + notes: [], + documents: [], + }; + expect( + changedReviewItems(before.digests, after.digests, selection), + ).toEqual(["company"]); + expect(changedReviewItems(after.digests, after.digests, selection)).toEqual( + [], + ); + }); +}); + +describe("the pipeline read model", () => { + it("totals every matching deal by currency and counts missing amounts", async () => { + const view = await loadPipeline( + await context({ + linked: { "9003": { id: partnerId, name: "Larchmont" } }, + }), + HubSpotPipelineQuerySchema.parse({}), + ); + expect(view.deals.map((d) => d.id).sort()).toEqual([ + "7002", + "7003", + "7004", + "7005", + "7006", + ]); + expect(view.summary.totals).toEqual([ + { currency: "EUR", amount: "185000", deals: 2 }, + { currency: "USD", amount: "45000", deals: 2 }, + ]); + expect(view.summary.missingAmounts).toBe(1); + expect(view.complete).toBe(true); + }); + + it("filters by link state with the same list the totals use", async () => { + const ctx = await context({ + linked: { "9001": { id: partnerId, name: "Northstar" } }, + }); + const partly = await loadPipeline( + ctx, + HubSpotPipelineQuerySchema.parse({ linked: "partly" }), + ); + expect(partly.deals.map((d) => d.id)).toEqual(["7006"]); + expect(partly.summary.deals).toBe(1); + const closed = await loadPipeline( + ctx, + HubSpotPipelineQuerySchema.parse({ state: "closed", linked: "partly" }), + ); + expect(closed.deals.map((d) => d.id)).toEqual(["7001"]); + }); +}); + +describe("HandoffReview", () => { + beforeEach(() => { + mocks.commit.mockReset(); + mocks.push.mockReset(); + }); + + async function renderReview( + props: Partial[0]> = {}, + ) { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: "7001", + }); + render( + , + ); + return source; + } + + it("starts with nothing optional selected and says what it will create", async () => { + await renderReview(); + for (const name of ["Ada Okafor", "Marcus Hale", "Ines Albright"]) + expect(screen.getByRole("checkbox", { name })).not.toBeChecked(); + expect( + screen.getByRole("checkbox", { name: /Halcyon order form/u }), + ).not.toBeChecked(); + expect(screen.getByLabelText("Commerce relationship status")).toHaveValue( + "prospect", + ); + expect( + screen.getByText(/independent of the deal’s sales stage \(Closed won\)/u), + ).toBeInTheDocument(); + expect( + screen.getByText( + "Create Northstar Systems as a partner with 1 deal reference.", + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + /the HubSpot owner, Priya Raman, has no Commerce mapping/u, + ), + ).toBeInTheDocument(); + }); + + it("commits the selection as one reviewed plan and opens the receipt", async () => { + mocks.commit.mockResolvedValue({ + ok: true, + value: { + kind: "committed", + partnerId, + importId: operationId, + pendingDocuments: 1, + }, + }); + await renderReview(); + fireEvent.click(screen.getByRole("checkbox", { name: "Ada Okafor" })); + fireEvent.click( + screen.getByRole("checkbox", { name: /Halcyon order form/u }), + ); + expect( + screen.getByText( + "Create Northstar Systems as a partner with 1 contact, 1 deal reference, and 1 document.", + ), + ).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Create partner" })); + await waitFor(() => expect(mocks.commit).toHaveBeenCalledTimes(1)); + const sent = HubSpotHandoffInputSchema.parse( + mocks.commit.mock.calls[0]?.[0], + ); + expect(sent).toMatchObject({ + contacts: [{ hubspotId: "5001", matchContactId: null }], + includeDeal: true, + endCustomerCompanyId: null, + documents: [{ fileId: "8001", kind: "other" }], + destination: { + kind: "new_partner", + status: "prospect", + ownerId: partnerId, + }, + }); + await waitFor(() => + expect(mocks.push).toHaveBeenCalledWith( + `/internal/partners/${partnerId}?handoff=${operationId}&start=1#hubspot-receipt`, + ), + ); + }); + + it("asks for a choice on a field both sides changed before committing", async () => { + await renderReview({ + destination: partner({ region: "Canada" }), + history: { region: { lastSource: "Mexico", lastResult: "Mexico" } }, + }); + expect(screen.getByText("Changed on both sides")).toBeInTheDocument(); + fireEvent.click( + screen.getByRole("button", { name: "Import selected changes" }), + ); + expect( + await screen.findByText(/Choose what happens to Region/u), + ).toBeInTheDocument(); + expect(mocks.commit).not.toHaveBeenCalled(); + }); + + it("resolves to the winning partner when another handoff linked the company first", async () => { + mocks.commit.mockResolvedValue({ + ok: true, + value: { + kind: "already_linked", + partnerId, + partnerName: "Northstar (theirs)", + }, + }); + await renderReview(); + fireEvent.click(screen.getByRole("button", { name: "Create partner" })); + expect( + await screen.findByText( + "This company was just linked to Northstar (theirs)", + ), + ).toBeInTheDocument(); + expect(mocks.push).not.toHaveBeenCalled(); + }); +}); + +describe("HubSpotReceipt", () => { + const record = (status: "pending" | "failed"): HubSpotImportRecord => ({ + id: operationId, + partnerId, + portalId: "4410", + companyId: "9001", + dealId: "7001", + status: "needs_attention", + createdPartner: true, + plan: { + operationId, + destination: { + kind: "new_partner", + partnerId, + name: "Northstar Systems", + status: "prospect", + ownerId: null, + ownerName: null, + models: [], + }, + fields: [], + contacts: [], + primaryContactId: null, + deal: null, + notes: [], + references: [], + documents: [], + dismissedSuggestions: [], + omitted: { contacts: 1, notes: 0, documents: 0, properties: 0 }, + }, + snapshot: { + portalId: "4410", + uiDomain: "app.hubspot.com", + retrievedAt: now.toISOString(), + company: { + id: "9001", + url: "https://app.hubspot.com/contacts/4410/record/0-2/9001", + name: "Northstar Systems", + updatedAt: null, + owner: null, + }, + values: [], + deal: null, + notes: [], + }, + items: [ + { + id: "019a44ae-0000-7000-8000-00000000e00a", + importId: operationId, + fileId: "8001", + noteId: "6001", + fileName: "Order form.pdf", + title: "Order form", + documentKind: "agreement", + status, + errorCode: status === "failed" ? "HUBSPOT_DOCUMENT_UNAVAILABLE" : null, + attachmentId: null, + existingAttachment: null, + excludedReason: null, + attempts: status === "failed" ? 1 : 0, + updatedAt: now.toISOString(), + }, + ], + createdById: partnerId, + createdByName: "Pat Seller", + createdAt: now.toISOString(), + completedAt: null, + }); + + it("separates the saved record from the unfinished handoff", () => { + render( + , + ); + expect( + screen.getByText("Partner created · 1 document still needs attention"), + ).toBeInTheDocument(); + expect( + screen.getByText( + "HubSpot would not provide this file. Retry, or exclude it.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Retry document" }), + ).toBeInTheDocument(); + expect(screen.getByText("1 contact")).toBeInTheDocument(); + }); + + it("excludes an item only with a reason, as an explicit amendment", async () => { + const [item] = record("failed").items; + if (!item) throw new Error("fixture item missing"); + const excluded = { + ...record("failed"), + status: "completed_with_exclusions" as const, + items: [ + { + ...item, + status: "excluded" as const, + excludedReason: "Not the signed copy", + }, + ], + }; + mocks.exclude.mockResolvedValue({ ok: true, value: excluded }); + render( + , + ); + fireEvent.click( + screen.getByRole("button", { name: "Exclude this item and finish" }), + ); + const confirm = screen.getByRole("button", { name: "Exclude" }); + expect(confirm).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Why leave it out?"), { + target: { value: "Not the signed copy" }, + }); + fireEvent.click(confirm); + await waitFor(() => + expect( + screen.getByText("Partner created · Completed with 1 item excluded"), + ).toBeInTheDocument(), + ); + expect(mocks.exclude).toHaveBeenCalledWith( + expect.objectContaining({ reason: "Not the signed copy" }), + ); + }); + + it("brings pending documents across one at a time right after the commit", async () => { + mocks.process.mockResolvedValue({ + ok: false, + code: "HUBSPOT_DOCUMENT_UNAVAILABLE", + }); + render( + , + ); + await waitFor(() => expect(mocks.process).toHaveBeenCalledTimes(1)); + const heading = screen.getByRole("heading", { + name: "HubSpot import receipt", + }); + expect(heading).toHaveFocus(); + const section = heading.closest("section"); + if (!section) throw new Error("receipt section missing"); + expect( + within(section).getByText("Continue in Commerce"), + ).toBeInTheDocument(); + }); +}); + +describe("HubSpotSetupGuide", () => { + it("names the next step from where this environment stands", () => { + expect( + hubspotSetupStage({ + tokenConfigured: false, + portalReachable: false, + active: false, + }), + ).toBe("token"); + expect( + hubspotSetupStage({ + tokenConfigured: true, + portalReachable: false, + active: false, + }), + ).toBe("deploy"); + expect( + hubspotSetupStage({ + tokenConfigured: true, + portalReachable: true, + active: false, + }), + ).toBe("approve"); + expect( + hubspotSetupStage({ + tokenConfigured: true, + portalReachable: true, + active: true, + }), + ).toBe("use"); + }); + + it("shows the read-only scopes and where the token goes, never a token", () => { + render(); + expect(screen.getByText("crm.objects.deals.read")).toBeInTheDocument(); + expect( + screen.getByText(/HUBSPOT_ACCESS_TOKEN to production/u), + ).toBeInTheDocument(); + expect(screen.getByText("Next")).toBeInTheDocument(); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + }); +}); + +describe("review findings stay fixed", () => { + const existing = (fields: Record, extra = {}) => + HubSpotHandoffInputSchema.parse({ + operationId, + connection: { id: partnerId, version: 3 }, + source: { companyId: "9001", dealId: null }, + reviewed: {}, + destination: { + kind: "existing_partner", + partnerId, + expectedVersion: 4, + fields, + }, + ...extra, + }); + + it("refuses a field changed on both sides when no choice was sent", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + const build = (fields: Record) => + buildHubSpotPlan({ + source, + handoff: existing(fields), + destination: partner({ region: "Canada" }), + history: { region: { lastSource: "Mexico", lastResult: "Mexico" } }, + ownerName: null, + newId: () => operationId, + }); + expect(() => build({})).toThrow("HUBSPOT_DECISION_REQUIRED"); + expect(build({ region: "keep" }).plan.fields[2]?.result).toBe("Canada"); + }); + + it("matches a hand-typed contact by its derived ID and never re-links another person", async () => { + const source = await loadHandoffSource(await context(), { + companyId: "9001", + dealId: null, + }); + const typed = withPartnerContactIds(partnerId, [ + { name: "Ada", email: "ada.okafor@northstar.example", role: "" }, + ]); + const { plan } = buildHubSpotPlan({ + source, + handoff: existing( + {}, + { contacts: [{ hubspotId: "5001", matchContactId: typed[0]?.id }] }, + ), + destination: partner({ contacts: typed }), + history: {}, + ownerName: null, + newId: () => operationId, + }); + expect(plan.contacts[0]).toMatchObject({ + id: typed[0]?.id, + matchedExisting: true, + }); + // A list that shifted under the review cannot pair Ada with someone else. + const shifted = withPartnerContactIds(partnerId, [ + { name: "Bob", email: "bob@northstar.example", role: "" }, + ]); + expect(() => + buildHubSpotPlan({ + source, + handoff: existing( + {}, + { contacts: [{ hubspotId: "5001", matchContactId: shifted[0]?.id }] }, + ), + destination: partner({ contacts: shifted }), + history: {}, + ownerName: null, + newId: () => operationId, + }), + ).toThrow("HUBSPOT_CONTACT_NOT_FOUND"); + const [first] = typed; + if (!first) throw new Error("fixture contact missing"); + const linked = [ + { + ...first, + source: { + provider: "hubspot" as const, + portalId: "4410", + objectId: "5002", + labels: [], + }, + }, + ]; + expect(() => + buildHubSpotPlan({ + source, + handoff: existing( + {}, + { contacts: [{ hubspotId: "5001", matchContactId: typed[0]?.id }] }, + ), + destination: partner({ contacts: linked }), + history: {}, + ownerName: null, + newId: () => operationId, + }), + ).toThrow("HUBSPOT_CONTACT_NOT_FOUND"); + }); + + it("reads no deal outside the approved pipelines", async () => { + const ctx = await context({ scope: { pipelines: ["renewals"] } }); + await expect( + loadHandoffSource(ctx, { companyId: "9001", dealId: "7001" }), + ).rejects.toThrow("HUBSPOT_SCOPE_DENIED"); + const view = await loadPipeline(ctx, HubSpotPipelineQuerySchema.parse({})); + expect(view.pipelines.map((p) => p.id)).toEqual(["renewals"]); + expect(view.deals.map((d) => d.id)).toEqual(["7008"]); + await expect( + loadPipeline( + await context({ scope: { pipelines: [] } }), + HubSpotPipelineQuerySchema.parse({}), + ), + ).rejects.toThrow("HUBSPOT_NO_PIPELINES"); + }); +}); diff --git a/apps/web/src/features/internal-ops/hubspot/model.ts b/apps/web/src/features/internal-ops/hubspot/model.ts new file mode 100644 index 00000000..3160a810 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/model.ts @@ -0,0 +1,203 @@ +import type { Route } from "next"; + +import { + hubspotErrorCodes, + hubspotPipelineSearchParams, + type HubSpotCompanyReference, + type HubSpotDealReference, + type HubSpotFile, + type HubSpotFieldDrift, + type HubSpotImportStatus, + type HubSpotItemStatus, + type HubSpotNoteReference, + type HubSpotPipelineQuery, + type HubSpotSourceValue, +} from "@clockwork/contracts"; + +import type { MessageId } from "@/src/i18n"; + +/** + * Everything a handoff review shows about the HubSpot side, read fresh + * through the approved scope. `digests` fingerprint each item as shown, so + * the commit can tell whether anything selected changed in the meantime. + */ +export interface HandoffSource { + portal: { portalId: string; uiDomain: string }; + retrievedAt: string; + company: { + id: string; + url: string; + name: string; + domain: string; + /** HubSpot's own website value, before normalization. */ + website: string | null; + country: string | null; + updatedAt: string | null; + owner: { id: string; name: string | null } | null; + }; + /** Proposed Commerce values after the bounded normalization. */ + proposed: { name: string; website: string; region: string }; + /** Approved additional properties, with their labels and types. */ + companyValues: readonly HubSpotSourceValue[]; + deal: { + reference: Omit; + values: readonly HubSpotSourceValue[]; + } | null; + contacts: readonly { + id: string; + name: string; + email: string; + role: string; + labels: readonly string[]; + }[]; + /** `null` when notes are outside the approved scope. */ + notes: readonly HubSpotNoteReference[] | null; + /** `null` when files are outside the approved scope. */ + files: readonly HubSpotFile[] | null; + digests: Readonly>; +} + +/** A deal's companies, for choosing which one becomes the partner. */ +export interface DealCompanyChoice { + deal: { + id: string; + name: string; + url: string; + stageLabel: string | null; + }; + companies: readonly (HubSpotCompanyReference & { + partner: { id: string; name: string } | null; + })[]; +} + +type Tone = "neutral" | "info" | "success" | "warning" | "danger"; + +export const importStatusLabels: Readonly< + Record +> = { + complete: "operations.hubspot.import.status.complete", + needs_attention: "operations.hubspot.import.status.needs_attention", + completed_with_exclusions: + "operations.hubspot.import.status.completed_with_exclusions", +}; + +export const importStatusTone: Readonly> = { + complete: "success", + needs_attention: "warning", + completed_with_exclusions: "info", +}; + +export const itemStatusLabels: Readonly> = + { + pending: "operations.hubspot.item.status.pending", + attached: "operations.hubspot.item.status.attached", + failed: "operations.hubspot.item.status.failed", + duplicate: "operations.hubspot.item.status.duplicate", + reused: "operations.hubspot.item.status.reused", + excluded: "operations.hubspot.item.status.excluded", + }; + +export const itemStatusTone: Readonly> = { + pending: "neutral", + attached: "success", + failed: "danger", + duplicate: "warning", + reused: "success", + excluded: "neutral", +}; + +export const driftLabels: Readonly> = { + never_imported: "operations.hubspot.drift.never_imported", + unchanged: "operations.hubspot.drift.unchanged", + hubspot_changed: "operations.hubspot.drift.hubspot_changed", + commerce_changed: "operations.hubspot.drift.commerce_changed", + both_changed: "operations.hubspot.drift.both_changed", +}; + +const otherErrors = [ + "INVALID_INPUT", + "CONTRACT_FORBIDDEN", + "CONTRACT_MFA_REQUIRED", + "CONTRACT_DEMO_UNAVAILABLE", + "SESSION_EXPIRED", + "PARTNER_VERSION_CONFLICT", + "PARTNER_NOT_FOUND", + "PARTNER_OWNER_NOT_STAFF", + "HUBSPOT_OWNER_NOT_STAFF", + "UNEXPECTED", +] as const; +type HubSpotMessageCode = + (typeof hubspotErrorCodes)[number] | (typeof otherErrors)[number]; + +const errorMessages = Object.fromEntries( + [...hubspotErrorCodes, ...otherErrors].map((code) => [ + code, + `operations.hubspot.error.${code}`, + ]), +) as Record; + +/** The message for a refusal; unknown codes read as unexpected. */ +export function hubspotErrorMessage(code: string): MessageId { + return errorMessages[code as HubSpotMessageCode] ?? errorMessages.UNEXPECTED; +} + +export const hubspotPickerPath = "/internal/hubspot" as Route; +export const pipelinePath = "/internal/pipeline" as Route; +export const hubspotSettingsPath = "/internal/owner/hubspot" as Route; + +/** The review for a company, optionally from a deal or into a partner. */ +export function handoffHref(input: { + company?: string; + deal?: string | null; + partner?: string | null; +}): Route { + const params = new URLSearchParams(); + if (input.company) params.set("company", input.company); + if (input.deal) params.set("deal", input.deal); + if (input.partner) params.set("partner", input.partner); + return `/internal/hubspot/review?${params.toString()}` as Route; +} + +export function pipelineHref(query: Partial): Route { + const search = hubspotPipelineSearchParams(query).toString(); + return search ? `${pipelinePath}?${search}` : pipelinePath; +} + +/** A partner page with this import's receipt open. */ +export function receiptHref( + partnerId: string, + importId: string, + options: { start?: boolean } = {}, +): Route { + return `/internal/partners/${partnerId}?handoff=${importId}${options.start ? "&start=1" : ""}#hubspot-receipt` as Route; +} + +/** Whole-number and decimal amounts as money in their own currency. */ +export function formatHubSpotAmount( + amount: string, + currency: string, + locale: string, +): string { + const value = Number(amount); + if (!Number.isFinite(value)) return `${amount} ${currency}`; + try { + return new Intl.NumberFormat(locale, { + style: "currency", + currency, + maximumFractionDigits: 2, + }).format(value); + } catch { + return `${amount} ${currency}`; + } +} + +/** A HubSpot close date is an instant; show the calendar day it names. */ +export function formatHubSpotDate(value: string | null, locale: string) { + if (!value) return null; + const time = Date.parse(value); + if (Number.isNaN(time)) return null; + return new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeZone: "UTC", + }).format(time); +} diff --git a/apps/web/src/features/internal-ops/hubspot/page-data.ts b/apps/web/src/features/internal-ops/hubspot/page-data.ts new file mode 100644 index 00000000..4c40dec6 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/page-data.ts @@ -0,0 +1,420 @@ +import "server-only"; + +import { z } from "zod"; + +import { + HubSpotIdSchema, + HubSpotPipelineQuerySchema, + withPartnerContactIds, + type HubSpotFieldHistory, + type HubSpotImportRecord, + type PartnerOwnerOption, + type PartnerRecord, +} from "@clockwork/contracts"; +import { HubSpotError } from "@clockwork/integrations/hubspot"; + +import { + explicitDemoIdentityEnabled, + getRequestCommerceSession, +} from "@/src/auth/session"; + +import { contractReader } from "../contracts/demo-access"; +import { + ContractAccessError, + sessionHas, + type ContractAccessFailure, + type ContractStaffSession, +} from "../contracts/server"; +import { + partnerReader, + partnerRepository, + partnerToday, +} from "../partners/server"; +import { + configuredPortal, + hubspotContext, + hubspotRepository, + HubSpotUnavailableError, + loadDealChoice, + loadDealPeople, + loadHandoffSource, + loadPartnerHubSpot, + loadPipeline, + searchHubSpot, + settingsCatalog, + similarPartnersFor, + type HubSpotContext, +} from "./server"; + +export type HubSpotLoaded = + | ({ kind: "ready" } & T) + | { kind: "denied"; code: ContractAccessFailure } + /** HubSpot itself: not configured, not connected, another portal, or a + * provider failure the page can explain. */ + | { kind: "unavailable"; code: string; canManage: boolean } + | { kind: "missing" } + | { kind: "error" }; + +async function loadWith( + permission: "sales:read" | "contract:write" | "staff:manage", + load: (session: ContractStaffSession) => Promise, +): Promise> { + let session: ContractStaffSession; + try { + session = await contractReader( + permission === "staff:manage" && explicitDemoIdentityEnabled() + ? "sales:read" + : permission, + getRequestCommerceSession, + ); + } catch (error) { + if (error instanceof ContractAccessError) + return { kind: "denied", code: error.code }; + console.error("hubspot: session failed", error); + return { kind: "error" }; + } + try { + return { kind: "ready", ...(await load(session)) }; + } catch (error) { + const canManage = sessionHas(session, "staff:manage"); + if (error instanceof ContractAccessError) + return { kind: "denied", code: error.code }; + if ( + error instanceof HubSpotUnavailableError || + error instanceof HubSpotError + ) + return { kind: "unavailable", code: error.code, canManage }; + if (error instanceof Error) { + if ( + [ + "PARTNER_NOT_FOUND", + "HUBSPOT_IMPORT_NOT_FOUND", + "HUBSPOT_RECORD_NOT_FOUND", + ].includes(error.message) + ) + return { kind: "missing" }; + if (/^HUBSPOT_[A-Z_]+$/.test(error.message)) + return { kind: "unavailable", code: error.message, canManage }; + } + console.error("hubspot: page load failed", error); + return { kind: "error" }; + } +} + +const mayImport = (session: ContractStaffSession) => + !explicitDemoIdentityEnabled() && sessionHas(session, "contract:write"); + +const optionalId = (value: unknown) => { + const first = Array.isArray(value) ? (value as unknown[])[0] : value; + return HubSpotIdSchema.safeParse(first).success ? (first as string) : null; +}; +const optionalGuid = (value: unknown) => { + const first = Array.isArray(value) ? (value as unknown[])[0] : value; + return z.guid().safeParse(first).success ? (first as string) : null; +}; +const text = (value: unknown) => { + const first = Array.isArray(value) ? (value as unknown[])[0] : value; + return typeof first === "string" ? first.slice(0, 200) : ""; +}; + +type Params = Record; + +/** The search-first picker: companies and deals, or a pasted record link. */ +export function loadPicker(params: Params) { + return loadWith("sales:read", async (session) => { + const q = text(params.q).trim(); + const context = await hubspotContext(); + return { + q, + /** A deal whose company is being chosen, kept as context. */ + deal: optionalId(params.deal), + /** A partner the chosen company will be linked to. */ + partner: optionalGuid(params.partner), + result: await searchHubSpot(context, q), + portalId: context.connection.portalId, + canImport: mayImport(session), + demo: context.demo, + }; + }); +} + +export type OwnerProposal = + | { kind: "mapped"; ownerId: string; hubspotOwner: string } + | { kind: "viewer"; ownerId: string; hubspotOwner: string | null } + | { kind: "none"; hubspotOwner: string | null }; + +/** + * The owner a new partner starts with: the staff member an administrator + * mapped the HubSpot owner to, otherwise the person reviewing, otherwise + * nobody. The reason is shown beside it. + */ +function proposeOwner( + context: HubSpotContext, + hubspotOwner: { id: string; name: string | null } | null, + owners: readonly PartnerOwnerOption[], + viewerId: string, +): OwnerProposal { + const mapped = hubspotOwner + ? context.connection.ownerMappings.find( + (row) => row.hubspotOwnerId === hubspotOwner.id, + ) + : undefined; + if (mapped && owners.some((owner) => owner.id === mapped.staffId)) + return { + kind: "mapped", + ownerId: mapped.staffId, + hubspotOwner: hubspotOwner?.name ?? hubspotOwner?.id ?? "", + }; + const hubspotName = hubspotOwner + ? (hubspotOwner.name ?? hubspotOwner.id) + : null; + if (owners.some((owner) => owner.id === viewerId)) + return { kind: "viewer", ownerId: viewerId, hubspotOwner: hubspotName }; + return { kind: "none", hubspotOwner: hubspotName }; +} + +/** + * The handoff review. A deal without a chosen company asks for one first; + * a company already linked opens as an update of its partner; `partner` + * links an unlinked company to an existing partner. + */ +export function loadReview(params: Params) { + return loadWith("sales:read", async (session) => { + const companyId = optionalId(params.company); + const dealId = optionalId(params.deal); + const requestedPartner = optionalGuid(params.partner); + const context = await hubspotContext(); + if (!companyId) { + if (!dealId) throw new Error("HUBSPOT_RECORD_NOT_FOUND"); + return { + step: "choose_company" as const, + choice: await loadDealChoice(context, dealId), + partner: requestedPartner, + demo: context.demo, + }; + } + const source = await loadHandoffSource(context, { companyId, dealId }); + const linked = ( + await context.store.partnersForCompanies(context.connection.portalId, [ + companyId, + ]) + ).get(companyId); + const partnerId = linked?.id ?? requestedPartner; + const reader = partnerReader(session); + let destination: PartnerRecord | null = null; + let history: HubSpotFieldHistory = {}; + let linkedElsewhere: string | null = null; + if (partnerId) { + const found = (await reader.get(partnerId, partnerToday())).partner; + destination = { + ...found, + contacts: withPartnerContactIds(found.id, found.contacts), + }; + if (!linked) { + const link = await context.store.linkForPartner( + partnerId, + context.connection.portalId, + ); + linkedElsewhere = link ? link.companyName : null; + } + history = await context.store.fieldHistory(partnerId); + } + const owners = await reader.owners(); + const suggestions = destination + ? [] + : await similarPartnersFor(context.store, source); + return { + step: "review" as const, + source, + connection: { + id: context.connection.id, + version: context.connection.version, + }, + destination, + /** A different company this partner is already linked to. */ + linkedElsewhere, + /** The partner page this company is already linked to, if any. */ + alreadyLinked: Boolean(linked), + /** The person asked for another partner than the linked one. */ + requestedOther: Boolean( + linked && requestedPartner && requestedPartner !== linked.id, + ), + history, + suggestions, + owners, + ownerProposal: proposeOwner( + context, + source.company.owner, + owners, + session.userId, + ), + canImport: mayImport(session), + demo: context.demo, + }; + }); +} + +/** The pipeline, filtered from the URL, with an open deal's people. */ +export function loadPipelinePage(params: Params) { + return loadWith("sales:read", async () => { + const query = HubSpotPipelineQuerySchema.parse(params); + const context = await hubspotContext(); + const view = await loadPipeline(context, query); + const selected = query.deal + ? (view.deals.find((deal) => deal.id === query.deal) ?? null) + : null; + return { + query, + view, + selected, + people: selected ? await loadDealPeople(context, selected.id) : [], + portal: context.portal, + demo: context.demo, + }; + }); +} + +/** The administrator's connection page. */ +export function loadSettings() { + return loadWith("staff:manage", async (session) => { + const demo = explicitDemoIdentityEnabled(); + const store = demo ? null : hubspotRepository(); + const [connection, portalResult] = await Promise.all([ + store + ? store.connection() + : Promise.resolve({ active: null, latest: null }), + configuredPortal().then( + (portal) => ({ ok: true as const, portal }), + (error: unknown) => ({ + ok: false as const, + code: + error instanceof HubSpotError ? error.code : "HUBSPOT_UNAVAILABLE", + }), + ), + ]); + const portal = portalResult.ok ? portalResult.portal : null; + // A failing catalog read leaves the page usable: the connection and its + // disconnect control still show, with the reason the catalog is missing. + let catalogError: string | null = null; + const catalog = + portal && !demo + ? await settingsCatalog().catch((error: unknown) => { + catalogError = + error instanceof HubSpotError + ? error.code + : "HUBSPOT_UNAVAILABLE"; + return null; + }) + : null; + return { + demo, + portal, + portalError: portalResult.ok ? null : portalResult.code, + catalogError, + active: connection.active, + latest: connection.latest, + catalog, + staff: store ? await partnerRepository().owners() : [], + canManage: !demo && sessionHas(session, "staff:manage"), + }; + }); +} + +/** A partner's import, for the receipt on the partner page. */ +export async function loadReceipt( + partnerId: string, + importId: string, +): Promise { + if (explicitDemoIdentityEnabled()) return null; + if (!z.guid().safeParse(importId).success) return null; + try { + return await hubspotRepository().import(partnerId, importId); + } catch (error) { + if (error instanceof Error && error.message === "HUBSPOT_IMPORT_NOT_FOUND") + return null; + throw error; + } +} + +/** + * The HubSpot parts of a partner page: the section, the receipt named in + * `?handoff=`, and a registration prepared from `?register=`. A failure here + * never takes the partner page down with it. + */ +export async function loadPartnerHubSpotParts( + partner: PartnerRecord, + params: Params, +) { + const handoffId = optionalGuid(params.handoff); + const registerId = optionalGuid(params.register); + const [view, receipt, register] = await Promise.all([ + loadPartnerHubSpot(partner, null).catch((error: unknown) => { + console.error("hubspot: partner section failed", error); + return null; + }), + handoffId ? loadReceipt(partner.id, handoffId).catch(() => null) : null, + registerId && registerId !== handoffId + ? loadReceipt(partner.id, registerId).catch(() => null) + : null, + ]); + const source = (register ?? receipt)?.plan.deal; + const dealSuggestion = + registerId && source + ? { + endClient: source.endCustomer?.name ?? "", + // The deal's other companies, none chosen, when the review left + // the end customer open. + choices: source.endCustomer + ? [] + : source.associatedCompanies + .filter((company) => company.id !== source.chosenCompanyId) + .map((company) => company.name), + // i18n-exempt: a stored note recording where a registration came from + notes: `HubSpot deal: ${source.name} (${source.url})`, + } + : null; + return { + view, + receipt, + dealSuggestion, + autoStart: firstValue(params.start) === "1", + }; +} + +const firstValue = (value: unknown) => + Array.isArray(value) ? (value as unknown[])[0] : value; + +/** + * MNDA suggestions from a HubSpot import: the partner's name as the + * proposed company, and the person chosen as primary contact (or the only + * person brought in) as a proposed signer. A contact is never treated as + * an authorized signer; the MNDA form asks the seller to confirm. + */ +export async function loadMndaPrefill(rawPartner: unknown, rawImport: unknown) { + const partnerId = optionalGuid(rawPartner); + const importId = optionalGuid(rawImport); + if (!partnerId || !importId) return null; + try { + await contractReader("sales:read", getRequestCommerceSession); + const record = await loadReceipt(partnerId, importId); + if (!record) return null; + const { plan } = record; + const contact = + plan.contacts.find((c) => c.id === plan.primaryContactId) ?? + (plan.contacts.length === 1 ? plan.contacts[0] : undefined); + const partner = await partnerRepository().get(partnerId, partnerToday()); + return { + company: partner.partner.name, + ...(contact + ? { + signerName: contact.name, + signerEmail: contact.email, + signerTitle: contact.role, + } + : {}), + }; + } catch (error) { + console.error("hubspot: MNDA suggestions failed", error); + return null; + } +} diff --git a/apps/web/src/features/internal-ops/hubspot/page-state.tsx b/apps/web/src/features/internal-ops/hubspot/page-state.tsx new file mode 100644 index 00000000..a0372cb3 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/page-state.tsx @@ -0,0 +1,142 @@ +import Link from "next/link"; + +import { ApplicationStatePanel, buttonClassName } from "@clockwork/ui"; + +import type { MessageId } from "@/src/i18n"; +import { getTranslations } from "@/src/i18n/server"; + +import { partnersPath } from "../partners/model"; +import { hubspotSettingsPath } from "./model"; +import type { HubSpotLoaded } from "./page-data"; +import styles from "./hubspot.module.css"; + +type Unready = Exclude, { kind: "ready" }>; + +const unavailableCopy: Readonly< + Record +> = { + HUBSPOT_NOT_CONFIGURED: { + title: "operations.hubspot.state.notConfiguredTitle", + body: "operations.hubspot.state.notConfiguredBody", + }, + HUBSPOT_NOT_CONNECTED: { + title: "operations.hubspot.state.notConnectedTitle", + body: "operations.hubspot.state.notConnectedBody", + }, + HUBSPOT_PORTAL_MISMATCH: { + title: "operations.hubspot.state.portalTitle", + body: "operations.hubspot.state.portalBody", + }, + HUBSPOT_RATE_LIMITED: { + title: "operations.hubspot.state.busyTitle", + body: "operations.hubspot.state.busyBody", + }, + HUBSPOT_SCOPE_DENIED: { + title: "operations.hubspot.state.scopeTitle", + body: "operations.hubspot.state.scopeBody", + }, + HUBSPOT_NO_PIPELINES: { + title: "operations.hubspot.state.noPipelinesTitle", + body: "operations.hubspot.state.noPipelinesBody", + }, + HUBSPOT_RECORD_ARCHIVED: { + title: "operations.hubspot.state.archivedTitle", + body: "operations.hubspot.state.archivedBody", + }, + HUBSPOT_NOT_ASSOCIATED: { + title: "operations.hubspot.state.notAssociatedTitle", + body: "operations.hubspot.state.notAssociatedBody", + }, +}; + +const fallback = { + title: "operations.hubspot.state.unavailableTitle", + body: "operations.hubspot.state.unavailableBody", +} as const satisfies { title: MessageId; body: MessageId }; + +/** + * What a HubSpot page shows when it cannot show HubSpot data, and where to + * go instead. Manual partner creation always stays one click away. + */ +export async function HubSpotPageState({ + state, + title, +}: { + state: Unready; + title: string; +}) { + const t = await getTranslations(); + const copy = + state.kind === "unavailable" + ? (unavailableCopy[state.code] ?? fallback) + : state.kind === "denied" + ? state.code === "CONTRACT_MFA_REQUIRED" + ? { + title: "operations.partners.access.mfaTitle" as const, + body: "operations.partners.access.mfaBody" as const, + } + : state.code === "CONTRACT_DEMO_UNAVAILABLE" + ? { + title: "operations.hubspot.state.demoTitle" as const, + body: "operations.hubspot.state.demoBody" as const, + } + : { + title: "operations.partners.access.forbiddenTitle" as const, + body: "operations.partners.access.forbiddenBody" as const, + } + : state.kind === "missing" + ? { + title: "operations.hubspot.state.missingTitle" as const, + body: "operations.hubspot.state.missingBody" as const, + } + : fallback; + return ( +
+

{title}

+ + {t("operations.hubspot.state.settings")} + + ) : ( + + {t("operations.partners.backToList")} + + ) + } + secondaryAction={ + state.kind === "unavailable" || state.kind === "missing" ? ( + + {t("operations.hubspot.picker.manual")} + + ) : undefined + } + /> +
+ ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/partner-section.tsx b/apps/web/src/features/internal-ops/hubspot/partner-section.tsx new file mode 100644 index 00000000..7506cf91 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/partner-section.tsx @@ -0,0 +1,262 @@ +import type { Route } from "next"; +import Link from "next/link"; +import { Fragment } from "react"; + +import { + InlineNotice, + StatusBadge, + Table, + buttonClassName, +} from "@clockwork/ui"; + +import type { Translator } from "@/src/i18n"; + +import { LocalTimestamp } from "../local-timestamp"; +import { + formatHubSpotAmount, + formatHubSpotDate, + handoffHref, + hubspotErrorMessage, + hubspotPickerPath, + importStatusLabels, + importStatusTone, +} from "./model"; +import type { PartnerHubSpotView } from "./server"; +import styles from "./hubspot.module.css"; + +/** + * The HubSpot part of a partner page, beside the Commerce sections it never + * replaces: what the partner is linked to, what HubSpot says now (read, never + * written into Commerce fields), the deals associated with the company, and + * every import. Association with a deal is not sourcing or credit. + */ +export function PartnerHubSpotSection({ + t, + locale, + partnerId, + view, + canEdit, +}: { + t: Translator; + locale: string; + partnerId: string; + view: PartnerHubSpotView | null; + canEdit: boolean; +}) { + if (!view || (!view.link && !view.imports.length)) + return canEdit && view ? ( +
+
+

{t("operations.hubspot.section.title")}

+ + {t("operations.hubspot.section.link")} + +
+

+ {t("operations.hubspot.section.notLinked")} +

+
+ ) : null; + const { link, live, imports } = view; + return ( +
+
+

{t("operations.hubspot.section.title")}

+
+ {live.kind === "ready" ? ( + + {t("operations.hubspot.openInHubSpot")} + + ) : null} + {link && canEdit && live.kind === "ready" ? ( + + {t("operations.hubspot.reviewUpdates")} + + ) : null} +
+
+ {link ? ( +
+
{t("operations.hubspot.section.company")}
+
+ + {live.kind === "ready" ? live.companyName : link.companyName} + +
+
{t("operations.hubspot.section.linkedBy")}
+
+ {link.createdByName} ·{" "} + +
+ {live.kind === "ready" ? ( + <> +
{t("operations.hubspot.section.checked")}
+
+ + {live.stale ? ( + <> + {" "} + + {t("operations.hubspot.stale")} + + + ) : null} +
+
{t("operations.hubspot.section.updated")}
+
+ {live.updatedAt ? ( + + ) : ( + t("operations.hubspot.notSet") + )} +
+ + ) : null} +
+ ) : null} + {live.kind === "ready" && live.changedFields > 0 ? ( + + {t("operations.hubspot.reviewUpdates")} + + ) : undefined + } + /> + ) : null} + {live.kind === "archived" ? ( + + ) : null} + {live.kind === "unavailable" ? ( + + ) : null} + {live.kind === "ready" && live.values.length ? ( +
+ {t("operations.hubspot.section.current")} +
+ {live.values.map((value) => ( + +
{value.label}
+
{value.value ?? t("operations.hubspot.notSet")}
+
+ ))} +
+
+ ) : null} + {live.kind === "ready" ? ( +
+

{t("operations.hubspot.section.dealsTitle")}

+

+ {t("operations.hubspot.section.dealsDisclaimer")} +

+ {live.deals.length ? ( + deal.id)} + numericColumns={[2]} + rows={live.deals.map((deal) => [ + + {deal.name} + , + deal.stageLabel ?? t("operations.hubspot.notSet"), + deal.amount && deal.currency + ? formatHubSpotAmount(deal.amount, deal.currency, locale) + : t("operations.hubspot.notSet"), + formatHubSpotDate(deal.closeDate, locale) ?? + t("operations.hubspot.notSet"), + ])} + /> + ) : ( +

+ {t("operations.hubspot.section.noDeals")} +

+ )} + + ) : null} +
+

{t("operations.hubspot.section.history")}

+ {imports.length ? ( +
    + {imports.map((record) => ( +
  • +
    + + {t( + record.createdPartner + ? "operations.hubspot.section.importCreated" + : "operations.hubspot.section.importUpdated", + { name: record.snapshot.company.name }, + )} + + + {t(importStatusLabels[record.status])} + +
    + + {record.createdByName} ·{" "} + + +
  • + ))} +
+ ) : ( +

+ {t("operations.hubspot.section.noImports")} +

+ )} +
+ + ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/picker.tsx b/apps/web/src/features/internal-ops/hubspot/picker.tsx new file mode 100644 index 00000000..e8cbbe1a --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/picker.tsx @@ -0,0 +1,252 @@ +import type { Route } from "next"; +import Link from "next/link"; + +import { + Breadcrumbs, + Button, + EmptyState, + InlineNotice, + Input, + PageHeader, + StatusBadge, + buttonClassName, +} from "@clockwork/ui"; + +import { breadcrumbsLabel } from "@/src/features/shared/ui-kit-labels"; +import type { Translator } from "@/src/i18n"; + +import { partnerHref, partnersPath } from "../partners/model"; +import { handoffHref, hubspotPickerPath } from "./model"; +import type { HubSpotSearch } from "./server"; +import styles from "./hubspot.module.css"; + +/** + * Search-first: nothing loads until someone searches, so the CRM directory + * is never listed wholesale. Results name the company's domain and owner and + * say when it is already linked in Commerce. + */ +export function HubSpotPicker({ + t, + q, + deal, + partner, + result, + portalId, + canImport, + demo, +}: { + t: Translator; + q: string; + deal: string | null; + partner: string | null; + result: HubSpotSearch; + portalId: string; + canImport: boolean; + demo: boolean; +}) { + const count = + result.kind === "results" + ? result.companies.length + (deal ? 0 : result.deals.length) + : 0; + return ( +
+ {label}} + /> + + {demo ? ( + + ) : !canImport ? ( + + ) : null} +
+ {deal ? : null} + {partner ? ( + + ) : null} + + + +

+ {result.kind === "results" + ? t("operations.hubspot.picker.announce", { count, query: q }) + : ""} +

+ {result.kind === "foreign_portal" ? ( + + ) : null} + {result.kind === "results" && count === 0 ? ( + + {t("operations.hubspot.picker.manual")} + + ) : undefined + } + /> + ) : null} + {result.kind === "results" && count > 0 ? ( +
+
+

+ {t("operations.hubspot.picker.companies")} +

+ {result.companies.length ? ( +
    + {result.companies.map((company) => ( +
  • +
    +

    {company.name}

    + {company.partner ? ( + + {t("operations.hubspot.picker.linked")} + + ) : null} +
    + + {[ + company.detail, + company.ownerName + ? t("operations.hubspot.ownerIs", { + name: company.ownerName, + }) + : t("operations.hubspot.noOwner"), + ] + .filter(Boolean) + .join(" · ")} + + {company.partner ? ( + + {t("operations.hubspot.picker.linkedTo", { + name: company.partner.name, + })} + + ) : null} +
    + {company.partner ? ( + <> + + {t("operations.hubspot.openPartner")} + + + {t("operations.hubspot.reviewUpdates")} + + + ) : ( + + {t("operations.hubspot.picker.review")} + + )} +
    +
  • + ))} +
+ ) : ( +

+ {t("operations.hubspot.picker.noCompanies")} +

+ )} +
+ {deal ? null : ( +
+

{t("operations.hubspot.picker.deals")}

+ {result.deals.length ? ( +
    + {result.deals.map((item) => ( +
  • +
    +

    {item.name}

    +
    + + {[ + item.detail, + item.ownerName + ? t("operations.hubspot.ownerIs", { + name: item.ownerName, + }) + : null, + ] + .filter(Boolean) + .join(" · ")} + +
    + + {t("operations.hubspot.picker.review")} + +
    +
  • + ))} +
+ ) : ( +

+ {t("operations.hubspot.picker.noDeals")} +

+ )} +
+ )} +
+ ) : null} + {result.kind === "empty" ? ( +

{t("operations.hubspot.picker.start")}

+ ) : null} +

+ + {t("operations.hubspot.picker.clear")} + +

+
+ ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/pipeline-actions.tsx b/apps/web/src/features/internal-ops/hubspot/pipeline-actions.tsx new file mode 100644 index 00000000..5e35b013 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/pipeline-actions.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; + +import { Button } from "@clockwork/ui"; + +import { useTranslations } from "@/src/i18n/client"; + +import { refreshHubSpotData } from "./actions"; + +/** Reads HubSpot again now. It imports nothing and changes no record. */ +export function RefreshHubSpotButton() { + const t = useTranslations(); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [failed, setFailed] = useState(false); + return ( + <> + + + {failed ? t("operations.hubspot.error.HUBSPOT_UNAVAILABLE") : ""} + + + ); +} + +/** Copies this filtered view's address, which carries no extra access. */ +export function CopyViewLink() { + const t = useTranslations(); + const [copied, setCopied] = useState(false); + return ( + <> + + + {copied ? t("operations.hubspot.pipeline.copied") : ""} + + + ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/pipeline.tsx b/apps/web/src/features/internal-ops/hubspot/pipeline.tsx new file mode 100644 index 00000000..d38f64c1 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/pipeline.tsx @@ -0,0 +1,570 @@ +import type { Route } from "next"; +import Link from "next/link"; + +import { + hubspotDealStates, + hubspotLinkFilters, + hubspotRecordUrl, + type HubSpotPipelineDeal, + type HubSpotPipelineQuery, + type HubSpotPortal, +} from "@clockwork/contracts"; +import { + Button, + EmptyState, + InlineNotice, + Input, + PageHeader, + Select, + StatusBadge, + Table, + Tag, + buttonClassName, +} from "@clockwork/ui"; + +import type { MessageId, Translator } from "@/src/i18n"; + +import { LocalTimestamp } from "../local-timestamp"; +import { partnerHref } from "../partners/model"; +import { + formatHubSpotAmount, + formatHubSpotDate, + handoffHref, + pipelineHref, + pipelinePath, +} from "./model"; +import { CopyViewLink, RefreshHubSpotButton } from "./pipeline-actions"; +import type { PipelineView } from "./server"; +import styles from "./hubspot.module.css"; + +const stateLabels: Readonly> = + { + open: "operations.hubspot.pipeline.state.open", + closed: "operations.hubspot.pipeline.state.closed", + all: "operations.hubspot.pipeline.state.all", + }; + +const linkLabels: Readonly> = + { + any: "operations.hubspot.pipeline.linked.any", + linked: "operations.hubspot.pipeline.linked.linked", + partly: "operations.hubspot.pipeline.linked.partly", + none: "operations.hubspot.pipeline.linked.none", + }; + +function amountText( + deal: Pick, + t: Translator, + locale: string, +) { + if (deal.amount && deal.currency) + return formatHubSpotAmount(deal.amount, deal.currency, locale); + return t("operations.hubspot.notSet"); +} + +function LinkedCompanies({ + deal, + t, +}: { + deal: HubSpotPipelineDeal; + t: Translator; +}) { + const linked = deal.companies.filter((company) => company.partner).length; + if (!deal.companies.length) + return ( + + {t("operations.hubspot.pipeline.noCompanies")} + + ); + return ( + + {t("operations.hubspot.pipeline.linkedCount", { + linked, + total: deal.companies.length, + })} + + ); +} + +/** + * HubSpot's pipeline inside Commerce, read-only. Counts and totals come from + * every deal that matches the filters, never from the visible page; amounts + * stay in their own currencies, and missing amounts are counted, not zeroed. + */ +export function PipelinePage({ + t, + locale, + query, + view, + selected, + people, + portal, + demo, +}: { + t: Translator; + locale: string; + query: HubSpotPipelineQuery; + view: PipelineView; + selected: HubSpotPipelineDeal | null; + people: readonly { + id: string; + name: string; + role: string; + labels: readonly string[]; + }[]; + portal: Pick; + demo: boolean; +}) { + const stageLabel = new Map(view.pipeline.stages.map((s) => [s.id, s.label])); + const totals = (list: PipelineView["summary"]["totals"]) => + list + .map((total) => formatHubSpotAmount(total.amount, total.currency, locale)) + .join(" · "); + const largest = Math.max( + 1, + ...view.summary.stages.map((stage) => stage.deals), + ); + // The panel's heading is the anchor, so opening a deal lower down the + // list brings its details into view, above the table on a phone. + const dealHref = (id: string) => + `${pipelineHref({ ...query, deal: id })}#deal-panel` as Route; + const close = pipelineHref({ ...query, deal: undefined }); + return ( +
+ + + + + } + /> + {demo ? ( + + ) : null} +

+ {t("operations.hubspot.pipeline.checked")}{" "} + + {view.stale ? ( + <> + {" "} + + {t("operations.hubspot.stale")} + + + ) : null} +

+ {view.stale ? ( + + ) : null} + {!view.complete ? ( + + ) : null} + +
+ ({ + value, + label: t(stateLabels[value]), + }))} + /> + ({ + value: owner.id, + label: owner.name, + })), + ]} + /> + + + ({ + value, + label: t(linkLabels[value]), + }))} + /> + + {query.view === "board" ? ( + + ) : null} +
+ + + {t("operations.hubspot.pipeline.clear")} + +
+ + +
+
+

+ {t("operations.hubspot.pipeline.summary", { + count: view.summary.deals, + })} +

+ +
+

+ {[ + view.summary.totals.length ? totals(view.summary.totals) : null, + view.summary.missingAmounts + ? t("operations.hubspot.pipeline.missing", { + count: view.summary.missingAmounts, + }) + : null, + view.summary.totals.length > 1 + ? t("operations.hubspot.pipeline.noCombined") + : null, + ] + .filter(Boolean) + .join(" · ") || t("operations.hubspot.pipeline.noAmounts")} +

+
    + {view.summary.stages.map((stage) => ( +
  1. + {stage.label} + {stage.deals} +
  2. + ))} +
+
+ +
+ {view.deals.length === 0 ? ( + + ) : query.view === "board" ? ( +
+ {view.pipeline.stages.map((stage) => { + const deals = view.deals.filter( + (deal) => deal.stageId === stage.id, + ); + return ( +
+

+ {stage.label} · {deals.length} +

+
    + {deals.map((deal) => ( +
  • + + {deal.name} + + + {deal.companies.map((c) => c.name).join(", ") || + t("operations.hubspot.pipeline.noCompanies")} + + {amountText(deal, t, locale)} + +
  • + ))} +
+
+ ); + })} +
+ ) : ( +
deal.id)} + rows={view.deals.map((deal) => [ + + {deal.name} + , + deal.companies.map((c) => c.name).join(", ") || ( + + {t("operations.hubspot.pipeline.noCompanies")} + + ), + (deal.stageId && stageLabel.get(deal.stageId)) ?? + t("operations.hubspot.notSet"), + amountText(deal, t, locale), + deal.ownerName ?? t("operations.hubspot.noOwner"), + formatHubSpotDate(deal.closeDate, locale) ?? + t("operations.hubspot.notSet"), + , + ])} + /> + )} + {selected ? ( + + ) : null} + +

+ + {t("operations.partners.backToList")} + +

+ + ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/plan.ts b/apps/web/src/features/internal-ops/hubspot/plan.ts new file mode 100644 index 00000000..dbe17c72 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/plan.ts @@ -0,0 +1,329 @@ +import { + classifyHubSpotDrift, + contractPdfFileName, + defaultHubSpotDecision, + hubspotFieldAction, + hubspotPartnerFieldSources, + hubspotPartnerFields, + type HubSpotFieldChange, + type HubSpotFieldHistory, + type HubSpotHandoffInput, + type HubSpotHandoffPlan, + type HubSpotPartnerField, + type HubSpotPlannedContact, + type HubSpotSourceSnapshot, + type HubSpotSourceValue, + type PartnerRecord, +} from "@clockwork/contracts"; + +import type { HandoffSource } from "./model"; + +export class HandoffPlanError extends Error { + constructor(readonly code: string) { + super(code); + } +} + +const refuse = (code: string): never => { + throw new HandoffPlanError(code); +}; + +/** The HubSpot value each partner field would take, already normalized. */ +export function proposedValue( + source: Pick, + field: HubSpotPartnerField, +): string { + return source.proposed[field]; +} + +function fieldSource(source: HandoffSource, field: HubSpotPartnerField) { + const { property, label } = hubspotPartnerFieldSources[field]; + const raw = + field === "name" + ? source.company.name + : field === "website" + ? (source.company.website ?? (source.company.domain || null)) + : source.company.country; + return { + property: + field === "website" && !source.company.website ? "domain" : property, + label, + value: raw || null, + }; +} + +/** + * Which reviewed items changed since the review was read: the company, the + * deal when it is used, and every selected person, note and file. An item + * the review never saw counts as changed. + */ +export function changedReviewItems( + reviewed: Readonly>, + current: Readonly>, + input: Pick< + HubSpotHandoffInput, + "includeDeal" | "endCustomerCompanyId" | "contacts" | "notes" | "documents" + > & { source: { dealId: string | null } }, +): string[] { + const keys = [ + "company", + ...(input.source.dealId && (input.includeDeal || input.endCustomerCompanyId) + ? ["deal"] + : []), + ...input.contacts.map((contact) => `contact:${contact.hubspotId}`), + ...input.notes.map((id) => `note:${id}`), + ...input.documents.map((document) => `file:${document.fileId}`), + ]; + return keys.filter( + (key) => !reviewed[key] || !current[key] || reviewed[key] !== current[key], + ); +} + +/** + * The plan a reviewed handoff commits, and the snapshot of what it read. It + * refuses anything the source does not offer rather than dropping it: a + * person, note, file or property that is not there, or outside the approved + * scope, stops the commit with a code the review can explain. + */ +export function buildHubSpotPlan(input: { + source: HandoffSource; + handoff: HubSpotHandoffInput; + /** The partner an update writes to; `null` for a new partner. */ + destination: PartnerRecord | null; + history: HubSpotFieldHistory; + ownerName: string | null; + newId: () => string; +}): { plan: HubSpotHandoffPlan; snapshot: HubSpotSourceSnapshot } { + const { source, handoff, destination, history } = input; + if (handoff.source.companyId !== source.company.id) + refuse("HUBSPOT_SOURCE_CHANGED"); + if (handoff.source.dealId !== (source.deal?.reference.id ?? null)) + refuse("HUBSPOT_NOT_ASSOCIATED"); + const target = handoff.destination; + if (target.kind === "existing_partner") { + if (!destination || destination.id !== target.partnerId) + refuse("PARTNER_NOT_FOUND"); + } else if (destination) refuse("HUBSPOT_OPERATION_CONFLICT"); + + const fields: HubSpotFieldChange[] = hubspotPartnerFields.map((field) => { + const hubspot = proposedValue(source, field); + const sourceRef = fieldSource(source, field); + if (target.kind === "new_partner") { + // A new partner takes what the person confirmed, which may correct + // HubSpot's spelling; the original stays in the snapshot. + const result = target[field]; + return { + field, + action: result ? "add" : "keep", + current: "", + hubspot, + result, + drift: "never_imported", + source: sourceRef, + }; + } + const current = destination?.[field] ?? ""; + const drift = classifyHubSpotDrift({ + lastSource: history[field]?.lastSource, + lastResult: history[field]?.lastResult, + commerce: current, + hubspot, + }); + const chosen = target.fields[field]; + // Changed on both sides: no default, so a missing choice is refused + // rather than read as "keep". + if (chosen === undefined && drift === "both_changed") + refuse("HUBSPOT_DECISION_REQUIRED"); + const decision = + chosen ?? + (field === "region" && drift === "never_imported" + ? "keep" + : (defaultHubSpotDecision({ drift, commerce: current, hubspot }) ?? + "keep")); + if (decision === "clear" && field === "name") + refuse("HUBSPOT_DECISION_REQUIRED"); + const { action, result } = hubspotFieldAction(decision, current, hubspot); + return { + field, + action, + current, + hubspot, + result, + drift, + source: sourceRef, + }; + }); + + const existingContacts = destination?.contacts ?? []; + const contacts: HubSpotPlannedContact[] = handoff.contacts.map((choice) => { + const person = + source.contacts.find((contact) => contact.id === choice.hubspotId) ?? + refuse("HUBSPOT_CONTACT_NOT_FOUND"); + const linked = existingContacts.find( + (contact) => + contact.source?.portalId === source.portal.portalId && + contact.source.objectId === person.id, + ); + if (choice.matchContactId) { + const match = existingContacts.find( + (contact) => contact.id === choice.matchContactId, + ); + // A match is to a contact with no HubSpot identity, or the same one; + // it never moves a contact from one HubSpot person to another. + if ( + !match || + // Matching is offered by email only; a list that shifted underneath + // the review can never pair a person with someone else. + !person.email || + match.email !== person.email || + (match.source && + !( + match.source.portalId === source.portal.portalId && + match.source.objectId === person.id + )) + ) + refuse("HUBSPOT_CONTACT_NOT_FOUND"); + } + const id = choice.matchContactId ?? linked?.id ?? input.newId(); + return { + id, + hubspotId: person.id, + name: person.name, + email: person.email, + role: person.role, + labels: person.labels, + matchedExisting: Boolean(choice.matchContactId ?? linked), + }; + }); + if (new Set(contacts.map((contact) => contact.id)).size !== contacts.length) + refuse("HUBSPOT_CONTACT_NOT_FOUND"); + const primary = handoff.primaryContact; + const primaryContactId = + primary === null + ? null + : primary.kind === "hubspot" + ? (contacts.find((contact) => contact.hubspotId === primary.id)?.id ?? + refuse("HUBSPOT_CONTACT_NOT_FOUND")) + : existingContacts.some((contact) => contact.id === primary.id) + ? primary.id + : refuse("HUBSPOT_CONTACT_NOT_FOUND"); + + let deal: HubSpotHandoffPlan["deal"] = null; + if (handoff.includeDeal) { + const reference = + source.deal?.reference ?? refuse("HUBSPOT_NOT_ASSOCIATED"); + const endCustomer = + handoff.endCustomerCompanyId === null + ? null + : (reference.associatedCompanies.find( + (company) => company.id === handoff.endCustomerCompanyId, + ) ?? refuse("HUBSPOT_NOT_ASSOCIATED")); + deal = { + ...reference, + chosenCompanyId: source.company.id, + endCustomer, + }; + } + + if (handoff.notes.length && source.notes === null) + refuse("HUBSPOT_SCOPE_DENIED"); + const notes = handoff.notes.map( + (id) => + source.notes?.find((note) => note.id === id) ?? + refuse("HUBSPOT_SCOPE_DENIED"), + ); + + const references: HubSpotSourceValue[] = handoff.referenceProperties.map( + (choice) => + (choice.objectType === "company" + ? source.companyValues + : (source.deal?.values ?? []) + ).find((value) => value.property === choice.name) ?? + refuse("HUBSPOT_SCOPE_DENIED"), + ); + + if (handoff.documents.length && source.files === null) + refuse("HUBSPOT_SCOPE_DENIED"); + const documents = handoff.documents.map((choice) => { + const file = + source.files?.find((candidate) => candidate.id === choice.fileId) ?? + refuse("HUBSPOT_DOCUMENT_UNAVAILABLE"); + if (file.availability !== "available") + refuse("HUBSPOT_DOCUMENT_UNAVAILABLE"); + return { + fileId: file.id, + noteId: file.noteId, + // The attachment store keeps names up to 200 characters. + fileName: contractPdfFileName(file.name.replace(/\.pdf$/i, "")), + title: choice.title, + kind: choice.kind, + size: file.size, + updatedAt: file.updatedAt, + }; + }); + + const plan: HubSpotHandoffPlan = { + operationId: handoff.operationId, + destination: + target.kind === "new_partner" + ? { + kind: "new_partner", + partnerId: target.partnerId, + name: target.name, + status: target.status, + ownerId: target.ownerId, + ownerName: input.ownerName, + models: target.models, + } + : { + kind: "existing_partner", + partnerId: target.partnerId, + name: destination?.name ?? "", + expectedVersion: target.expectedVersion, + }, + fields, + contacts, + primaryContactId, + deal, + notes, + references, + documents, + dismissedSuggestions: + target.kind === "new_partner" ? target.dismissedSuggestions : [], + omitted: { + contacts: source.contacts.length - contacts.length, + notes: (source.notes?.length ?? 0) - notes.length, + documents: + (source.files?.filter((file) => file.availability === "available") + .length ?? 0) - documents.length, + properties: + source.companyValues.length + + (source.deal?.values.length ?? 0) - + references.length, + }, + }; + + const fieldValues: HubSpotSourceValue[] = fields.map((field) => ({ + objectType: "company", + property: field.source.property, + label: field.source.label, + type: "string", + value: field.source.value, + })); + const snapshot: HubSpotSourceSnapshot = { + portalId: source.portal.portalId, + uiDomain: source.portal.uiDomain, + retrievedAt: source.retrievedAt, + company: { + id: source.company.id, + url: source.company.url, + name: source.company.name, + updatedAt: source.company.updatedAt, + owner: source.company.owner, + }, + values: [...fieldValues, ...references], + deal, + notes, + }; + return { plan, snapshot }; +} diff --git a/apps/web/src/features/internal-ops/hubspot/receipt.tsx b/apps/web/src/features/internal-ops/hubspot/receipt.tsx new file mode 100644 index 00000000..38776e33 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/receipt.tsx @@ -0,0 +1,575 @@ +"use client"; + +import type { Route } from "next"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState, useTransition } from "react"; + +import { + countHubSpotPlan, + hubspotImportStatus, + type HubSpotFieldAction, + type HubSpotImportItem, + type HubSpotImportRecord, +} from "@clockwork/contracts"; +import { + Button, + InlineNotice, + Input, + StatusBadge, + Tag, + buttonClassName, +} from "@clockwork/ui"; + +import { useFormattingLocale, useTranslations } from "@/src/i18n/client"; +import type { MessageId } from "@/src/i18n"; + +import { LocalTimestamp } from "../local-timestamp"; +import { partnerEditHref } from "../partners/model"; +import { + excludeHubSpotDocument, + processHubSpotDocument, + reuseHubSpotDocument, +} from "./actions"; +import { + formatHubSpotAmount, + formatHubSpotDate, + hubspotErrorMessage, + importStatusLabels, + importStatusTone, + itemStatusLabels, + itemStatusTone, +} from "./model"; +import styles from "./hubspot.module.css"; + +const actionLabels: Readonly> = { + add: "operations.hubspot.receipt.added", + replace: "operations.hubspot.receipt.replaced", + clear: "operations.hubspot.receipt.cleared", + keep: "operations.hubspot.receipt.kept", +}; + +const fieldLabels: Readonly> = { + name: "operations.partners.field.name", + website: "operations.partners.field.website", + region: "operations.partners.field.region", +}; + +/** + * What a handoff did, item by item. "Record saved" and "Handoff complete" + * are separate facts: the partner is usable as soon as it is saved, but the + * handoff is complete only when every selected document is attached, reused + * or explicitly excluded, and an exclusion is said out loud. + */ +export function HubSpotReceipt({ + record: initial, + canEdit, + autoStart, + focus, +}: { + record: HubSpotImportRecord; + canEdit: boolean; + /** Bring pending documents across now, right after the commit. */ + autoStart: boolean; + /** Move focus here: the person just finished the review. */ + focus: boolean; +}) { + const t = useTranslations(); + const locale = useFormattingLocale(); + const router = useRouter(); + const heading = useRef(null); + const [record, setRecord] = useState(initial); + const [busy, setBusy] = useState(null); + const [failure, setFailure] = useState(null); + const [excluding, setExcluding] = useState<{ + id: string; + reason: string; + } | null>(null); + const [, startTransition] = useTransition(); + const started = useRef(false); + const [announcement, setAnnouncement] = useState(""); + + useEffect(() => setRecord(initial), [initial]); + useEffect(() => { + if (focus) heading.current?.focus(); + }, [focus]); + + async function act( + item: HubSpotImportItem, + run: () => Promise< + { ok: true; value: HubSpotImportRecord } | { ok: false; code: string } + >, + ) { + setBusy(item.id); + setFailure(null); + const result = await run().catch(() => ({ + ok: false as const, + code: "UNEXPECTED", + })); + setBusy(null); + if (!result.ok) { + setFailure(result.code); + return; + } + setRecord(result.value); + const updated = result.value.items.find((i) => i.id === item.id); + if (updated) + setAnnouncement( + t("operations.hubspot.receipt.itemAnnounce", { + title: updated.title, + status: t(itemStatusLabels[updated.status]), + }), + ); + startTransition(() => router.refresh()); + } + + const process = (item: HubSpotImportItem) => + act(item, () => + processHubSpotDocument({ + partnerId: record.partnerId, + importId: record.id, + itemId: item.id, + }), + ); + + // Right after the commit, documents come across one at a time so each has + // its own result; anything that fails waits here for a person. + useEffect(() => { + if (!autoStart || !canEdit || started.current) return; + started.current = true; + void (async () => { + for (const item of initial.items) + if (item.status === "pending" && item.attempts === 0) + await process(item); + })(); + // Once, on arrival. + }, []); + + const status = hubspotImportStatus(record.items); + const counts = countHubSpotPlan(record.plan); + const open = record.items.filter((item) => + ["pending", "failed", "duplicate"].includes(item.status), + ).length; + const excluded = record.items.filter( + (item) => item.status === "excluded", + ).length; + const saved = t( + record.createdPartner + ? "operations.hubspot.receipt.partnerCreated" + : "operations.hubspot.receipt.recordSaved", + ); + const state = + status === "needs_attention" + ? t("operations.hubspot.receipt.needsAttention", { count: open }) + : status === "completed_with_exclusions" + ? t("operations.hubspot.receipt.withExclusions", { count: excluded }) + : t("operations.hubspot.receipt.complete"); + const deal = record.plan.deal; + const changedFields = record.plan.fields.filter( + (field) => field.action !== "keep", + ); + + return ( +
+
+

+ {t("operations.hubspot.receipt.title")} +

+ + {t(importStatusLabels[status])} + +
+

+ {saved} · {state} +

+

+ {announcement} +

+ {status === "needs_attention" ? ( +

{t("operations.hubspot.receipt.usable")}

+ ) : null} + {failure ? ( + + ) : null} + +
+
{t("operations.hubspot.receipt.source")}
+
+ + {record.snapshot.company.name} + + {deal ? ( + <> + {" · "} + + {deal.name} + + + ) : null} +
+
{t("operations.hubspot.receipt.by")}
+
+ {record.createdByName} ·{" "} + +
+
{t("operations.hubspot.receipt.copied")}
+
+ {changedFields.length || + counts.contactsAdded || + counts.contactsLinked ? ( +
    + {changedFields.map((field) => ( +
  • + + {t(actionLabels[field.action], { + field: t( + fieldLabels[field.field] ?? + "operations.partners.field.name", + ), + value: field.result, + })} + + {field.result !== field.hubspot && field.hubspot ? ( + + {t("operations.hubspot.inHubSpot", { + value: field.hubspot, + })} + + ) : null} +
  • + ))} + {record.plan.contacts.map((contact) => ( +
  • + + {t( + contact.matchedExisting + ? "operations.hubspot.receipt.contactLinked" + : "operations.hubspot.receipt.contactAdded", + { name: contact.name }, + )} + {record.plan.primaryContactId === contact.id + ? ` · ${t("operations.hubspot.receipt.primary")}` + : ""} + + {contact.labels.length ? ( + + {contact.labels.map((label) => ( + {label} + ))} + + ) : null} +
  • + ))} +
+ ) : ( + t("operations.hubspot.receipt.nothingCopied") + )} +
+
{t("operations.hubspot.receipt.reference")}
+
+ {deal || record.plan.notes.length || record.plan.references.length ? ( +
    + {deal ? ( +
  • + + {t("operations.hubspot.receipt.deal", { + name: deal.name, + stage: deal.stage.label ?? t("operations.hubspot.notSet"), + })} + + + {[ + deal.amount && deal.currency + ? formatHubSpotAmount( + deal.amount, + deal.currency, + locale, + ) + : t("operations.hubspot.receipt.amountNotSet"), + formatHubSpotDate(deal.closeDate, locale) + ? t("operations.hubspot.receipt.closes", { + date: + formatHubSpotDate(deal.closeDate, locale) ?? "", + }) + : null, + deal.endCustomer + ? t("operations.hubspot.receipt.endCustomer", { + name: deal.endCustomer.name, + }) + : null, + ] + .filter(Boolean) + .join(" · ")} + +
  • + ) : null} + {record.plan.notes.map((note) => ( +
  • +
    + + {t("operations.hubspot.notes.label", { + author: + note.authorName ?? + t("operations.hubspot.notes.unknownAuthor"), + date: + formatHubSpotDate(note.timestamp, locale) ?? + t("operations.hubspot.notSet"), + })} + +

    + {note.body} +

    +
    +
  • + ))} + {record.plan.references.map((value) => ( +
  • + + {value.label}:{" "} + {value.value ?? t("operations.hubspot.notSet")} + +
  • + ))} +
+ ) : ( + t("operations.hubspot.receipt.nothingKept") + )} +
+
{t("operations.hubspot.receipt.leftOut")}
+
+ {[ + record.plan.omitted.contacts + ? t("operations.hubspot.summary.contacts", { + count: record.plan.omitted.contacts, + }) + : "", + record.plan.omitted.notes + ? t("operations.hubspot.summary.notes", { + count: record.plan.omitted.notes, + }) + : "", + record.plan.omitted.documents + ? t("operations.hubspot.summary.documents", { + count: record.plan.omitted.documents, + }) + : "", + record.plan.omitted.properties + ? t("operations.hubspot.summary.references", { + count: record.plan.omitted.properties, + }) + : "", + ] + .filter(Boolean) + .join(", ") || t("operations.hubspot.receipt.nothingLeftOut")} +
+
+ + {record.items.length ? ( +
+

{t("operations.hubspot.group.documents")}

+
    + {record.items.map((item) => ( +
  • +
    + {item.title} + + {t(itemStatusLabels[item.status])} + +
    + {item.fileName} + {item.status === "failed" && item.errorCode ? ( + + {t(hubspotErrorMessage(item.errorCode))} + + ) : null} + {item.status === "duplicate" && item.existingAttachment ? ( + + {t("operations.hubspot.receipt.duplicate", { + title: item.existingAttachment.title, + })} + + ) : null} + {item.status === "excluded" && item.excludedReason ? ( + + {t("operations.hubspot.receipt.excludedBecause", { + reason: item.excludedReason, + })} + + ) : null} + {canEdit && + ["pending", "failed", "duplicate"].includes(item.status) ? ( + excluding?.id === item.id ? ( +
    + + setExcluding({ + id: item.id, + reason: event.currentTarget.value, + }) + } + /> +
    + + +
    +
    + ) : ( +
    + {item.status === "duplicate" ? ( + + ) : item.errorCode === + "HUBSPOT_DOCUMENT_CHANGED" ? null : ( + + )} + +
    + ) + ) : null} +
  • + ))} +
+

+ {t("operations.hubspot.receipt.appendOnly")} +

+
+ ) : null} + +
+

{t("operations.hubspot.continue.title")}

+

{t("operations.hubspot.continue.body")}

+
+ {canEdit ? ( + + {t("operations.hubspot.continue.relationship")} + + ) : null} + {canEdit && deal ? ( + + {t("operations.hubspot.continue.register")} + + ) : null} + + {t("operations.hubspot.continue.mnda")} + + {record.items.some( + (item) => item.status === "attached" || item.status === "reused", + ) ? ( + + {t("operations.hubspot.continue.documents")} + + ) : null} + + {t("operations.hubspot.continue.source")} + +
+ {deal ? ( +

+ {t("operations.hubspot.continue.registrationNotCreated")} +

+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/review-page.tsx b/apps/web/src/features/internal-ops/hubspot/review-page.tsx new file mode 100644 index 00000000..461f8a48 --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/review-page.tsx @@ -0,0 +1,209 @@ +import type { Route } from "next"; +import Link from "next/link"; + +import { + Breadcrumbs, + Button, + InlineNotice, + PageHeader, + StatusBadge, + Tag, + buttonClassName, +} from "@clockwork/ui"; + +import { breadcrumbsLabel } from "@/src/features/shared/ui-kit-labels"; +import type { Translator } from "@/src/i18n"; + +import { partnerHref, partnersPath } from "../partners/model"; +import { hubspotPickerPath, type DealCompanyChoice } from "./model"; +import type { loadReview } from "./page-data"; +import { HandoffReview } from "./review"; +import styles from "./hubspot.module.css"; + +type Ready = Extract>, { kind: "ready" }>; + +/** + * A deal names its companies; which one becomes the Commerce partner is a + * person's choice. Every option starts unselected, and a HubSpot "Primary" + * label is shown as context, never as a default. + */ +function DealChoice({ + t, + choice, + partner, +}: { + t: Translator; + choice: DealCompanyChoice; + partner: string | null; +}) { + return ( +
+ + {partner ? : null} + {choice.companies.length ? ( +
+ + {t("operations.hubspot.choose.legend")} + +

{t("operations.hubspot.choose.body")}

+ {choice.companies.map((company) => ( + + ))} +
+ +
+
+ ) : ( + <> + +
+ + {t("operations.hubspot.choose.pick")} + +
+ + )} + + ); +} + +/** The handoff review page: a deal's company choice, or the review itself. */ +export function ReviewPage({ t, loaded }: { t: Translator; loaded: Ready }) { + const crumbs = [ + { label: t("operations.partners.title"), href: partnersPath }, + { label: t("operations.hubspot.picker.title"), href: hubspotPickerPath }, + { label: t("operations.hubspot.review.title") }, + ]; + if (loaded.step === "choose_company") + return ( +
+ ( + {label} + )} + /> + + {t("operations.hubspot.openInHubSpot")} + + } + /> + +
+ ); + const destination = loaded.destination; + return ( +
+ {label}} + /> + + {loaded.requestedOther && destination ? ( + + {t("operations.hubspot.openPartner")} + + } + /> + ) : null} + +
+ ); +} diff --git a/apps/web/src/features/internal-ops/hubspot/review.tsx b/apps/web/src/features/internal-ops/hubspot/review.tsx new file mode 100644 index 00000000..a5c8cbed --- /dev/null +++ b/apps/web/src/features/internal-ops/hubspot/review.tsx @@ -0,0 +1,1745 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + useEffect, + useMemo, + useRef, + useState, + useTransition, + type ReactNode, +} from "react"; + +import { + HubSpotHandoffInputSchema, + accountDocumentKinds, + countHubSpotPlan, + defaultHubSpotDecision, + classifyHubSpotDrift, + hubspotPartnerFields, + hubspotPlanIsLinkOnly, + partnerModels, + partnerStatuses, + type AccountDocumentKind, + type HubSpotFieldDecision, + type HubSpotFieldHistory, + type HubSpotHandoffPlan, + type HubSpotPartnerField, + type PartnerModel, + type PartnerOwnerOption, + type PartnerRecord, + type PartnerStatus, +} from "@clockwork/contracts"; +import { + Button, + Checkbox, + Fieldset, + InlineNotice, + Input, + Select, + StatusBadge, + Tag, + buttonClassName, +} from "@clockwork/ui"; + +import { useUnsavedChangesWarning } from "@/src/features/customer-partner/unsaved-changes"; +import { useFormattingLocale, useTranslations } from "@/src/i18n/client"; +import type { MessageId, Translator } from "@/src/i18n"; + +import { LocalTimestamp } from "../local-timestamp"; +import { + partnerHref, + partnerModelLabels, + partnerStatusLabels, +} from "../partners/model"; +import { commitHubSpotHandoff } from "./actions"; +import { + driftLabels, + formatHubSpotAmount, + formatHubSpotDate, + handoffHref, + hubspotErrorMessage, + hubspotPickerPath, + receiptHref, + type HandoffSource, +} from "./model"; +import type { OwnerProposal } from "./page-data"; +import { buildHubSpotPlan } from "./plan"; +import styles from "./hubspot.module.css"; + +const fieldLabels: Readonly> = { + name: "operations.partners.field.name", + website: "operations.partners.field.website", + region: "operations.partners.field.region", +}; + +const documentKindLabels: Readonly> = { + mnda: "operations.accountDocuments.kind.mnda", + agreement: "operations.accountDocuments.kind.agreement", + other: "operations.accountDocuments.kind.other", +}; + +interface Suggestion { + id: string; + name: string; + website: string; + reason: "name" | "website"; + linkedCompanyId: string | null; +} + +interface ContactChoice { + selected: boolean; + /** "new", an existing contact ID, or "" while the person has not chosen. */ + match: string; +} + +interface DocumentChoice { + selected: boolean; + title: string; + kind: AccountDocumentKind; +} + +function bytes(size: number | null, locale: string): string | null { + if (size === null) return null; + const units = ["byte", "kilobyte", "megabyte"] as const; + let value = size; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return new Intl.NumberFormat(locale, { + style: "unit", + unit: units[unit], + unitDisplay: "narrow", + maximumFractionDigits: 1, + }).format(value); +} + +/** The footer sentence, from the same plan the commit uses. */ +function planSummary( + plan: HubSpotHandoffPlan, + t: Translator, + locale: string, + companyName: string, +): string { + const counts = countHubSpotPlan(plan); + const list = (parts: string[]) => + new Intl.ListFormat(locale, { type: "conjunction" }).format(parts); + const context = [ + counts.contactsAdded + ? t("operations.hubspot.summary.contacts", { + count: counts.contactsAdded, + }) + : "", + counts.contactsLinked + ? t("operations.hubspot.summary.contactsLinked", { + count: counts.contactsLinked, + }) + : "", + counts.deals + ? t("operations.hubspot.summary.deals", { count: counts.deals }) + : "", + counts.notes + ? t("operations.hubspot.summary.notes", { count: counts.notes }) + : "", + counts.references + ? t("operations.hubspot.summary.references", { count: counts.references }) + : "", + counts.documents + ? t("operations.hubspot.summary.documents", { count: counts.documents }) + : "", + ].filter(Boolean); + if (counts.create) + return context.length + ? t("operations.hubspot.summary.create", { + name: plan.destination.name, + parts: list(context), + }) + : t("operations.hubspot.summary.createOnly", { + name: plan.destination.name, + }); + if (hubspotPlanIsLinkOnly(plan)) + return t("operations.hubspot.summary.linkOnly", { + company: companyName, + partner: plan.destination.name, + }); + const verb = (id: MessageId, count: number) => + count ? t(id, { count }) : ""; + const changes = [ + verb("operations.hubspot.summary.added", counts.added), + verb("operations.hubspot.summary.replaced", counts.replaced), + verb("operations.hubspot.summary.cleared", counts.cleared), + verb("operations.hubspot.summary.addContacts", counts.contactsAdded), + verb("operations.hubspot.summary.linkContacts", counts.contactsLinked), + verb("operations.hubspot.summary.keepDeals", counts.deals), + verb("operations.hubspot.summary.keepNotes", counts.notes), + verb("operations.hubspot.summary.keepReferences", counts.references), + verb("operations.hubspot.summary.attachDocuments", counts.documents), + ].filter(Boolean); + const kept = plan.fields.filter( + (field) => field.action === "keep" && field.current !== "", + ).length; + const text = changes.length + ? list(changes) + : t("operations.hubspot.summary.noChanges"); + const sentence = kept + ? t("operations.hubspot.summary.update", { + changes: text, + kept: t("operations.hubspot.summary.kept", { count: kept }), + }) + : t("operations.hubspot.summary.updateOnly", { changes: text }); + // Staff copy is English: the sentence starts with a capital. + return sentence.charAt(0).toUpperCase() + sentence.slice(1); +} + +function Endpoint({ + label, + name, + detail, + link, +}: { + label: string; + name: string; + detail?: ReactNode; + link?: ReactNode; +}) { + return ( +
+ {label} + {name} + {detail} + {link} +
+ ); +} + +/** + * One coherent handoff: the HubSpot source on the left, the Commerce partner + * it becomes or updates on the right, and every group of information a + * person may bring across. Nothing optional starts selected except the + * originating deal as context. The footer states the plan's result in plain + * words and the one action that commits it. + */ +export function HandoffReview({ + source, + connection, + destination, + history, + suggestions, + owners, + ownerProposal, + linkedElsewhere, + alreadyLinked = false, + canImport, + demo, +}: { + source: HandoffSource; + connection: { id: string; version: number }; + destination: PartnerRecord | null; + history: HubSpotFieldHistory; + suggestions: readonly Suggestion[]; + owners: readonly PartnerOwnerOption[]; + ownerProposal: OwnerProposal; + linkedElsewhere: string | null; + /** The company is already this partner's: a review with nothing chosen + * has nothing to commit. */ + alreadyLinked?: boolean; + canImport: boolean; + demo: boolean; +}) { + const t = useTranslations(); + const locale = useFormattingLocale(); + const router = useRouter(); + const operationId = useRef(null); + const newPartnerId = useRef(null); + const summaryRef = useRef(null); + const [pending, startTransition] = useTransition(); + const [dirty, setDirty] = useState(false); + const [submitted, setSubmitted] = useState(false); + const [failure, setFailure] = useState(null); + const [changedItems, setChangedItems] = useState(null); + const [linkedFirst, setLinkedFirst] = useState<{ + partnerId: string; + partnerName: string; + } | null>(null); + const [showErrors, setShowErrors] = useState(false); + const [confirmingCancel, setConfirmingCancel] = useState(false); + + // Destination: a new partner's proposed details, or an update's decisions. + const [name, setName] = useState(source.proposed.name); + const [website, setWebsite] = useState(source.proposed.website); + const [region, setRegion] = useState(source.proposed.region); + const [status, setStatus] = useState("prospect"); + const [ownerId, setOwnerId] = useState( + ownerProposal.kind === "none" ? "" : ownerProposal.ownerId, + ); + const [models, setModels] = useState([]); + const [dismissed, setDismissed] = useState< + Record + >({}); + const drift = (field: HubSpotPartnerField) => + classifyHubSpotDrift({ + lastSource: history[field]?.lastSource, + lastResult: history[field]?.lastResult, + commerce: destination?.[field] ?? "", + hubspot: source.proposed[field], + }); + // What each field starts as. Region is never filled by default for a new + // link: only name and website are default mappings. + const startingDecision = ( + field: HubSpotPartnerField, + ): HubSpotFieldDecision | null => { + if (!destination) return "keep"; + const fieldDrift = drift(field); + if (field === "region" && fieldDrift === "never_imported") return "keep"; + return defaultHubSpotDecision({ + drift: fieldDrift, + commerce: destination[field], + hubspot: source.proposed[field], + }); + }; + const comparison = hubspotPartnerFields + .map((field) => + [ + field, + drift(field), + destination?.[field] ?? "", + source.proposed[field], + ].join("\u0000"), + ) + .join("\u0001"); + const [decisions, setDecisions] = useState< + Record + >( + () => + Object.fromEntries( + hubspotPartnerFields.map((field) => [field, startingDecision(field)]), + ) as Record, + ); + // After a refresh (HubSpot or the partner changed underneath the review), + // a field whose comparison moved starts over: one that is now changed on + // both sides needs a fresh choice, so a colleague's edit is never + // overwritten by a choice made against the earlier values. + const seenComparison = useRef(comparison); + const seenFields = useRef( + Object.fromEntries( + hubspotPartnerFields.map((field) => [ + field, + [drift(field), destination?.[field] ?? "", source.proposed[field]].join( + "\u0000", + ), + ]), + ) as Record, + ); + const seenDestination = useRef(destination?.id ?? null); + useEffect(() => { + if (seenComparison.current === comparison) return; + seenComparison.current = comparison; + const destinationChanged = + seenDestination.current !== (destination?.id ?? null); + seenDestination.current = destination?.id ?? null; + // Worked out here, outside the state update, so the update stays pure. + const resets: Partial< + Record + > = {}; + for (const field of hubspotPartnerFields) { + const key = [ + drift(field), + destination?.[field] ?? "", + source.proposed[field], + ].join("\u0000"); + if (destinationChanged || seenFields.current[field] !== key) + resets[field] = startingDecision(field); + seenFields.current[field] = key; + } + if (Object.keys(resets).length) + setDecisions((current) => ({ ...current, ...resets })); + if (destinationChanged) { + setSubmitted(false); + setLinkedFirst(null); + } + // `comparison` captures everything the decisions depend on. + }, [comparison]); + + // Groups: nothing optional starts selected; the deal is kept as context. + const [contacts, setContacts] = useState>({}); + const [primary, setPrimary] = useState(""); + const [includeDeal, setIncludeDeal] = useState(source.deal !== null); + const [endCustomer, setEndCustomer] = useState(""); + const [notes, setNotes] = useState([]); + const [references, setReferences] = useState([]); + const [documents, setDocuments] = useState>( + {}, + ); + + useUnsavedChangesWarning(dirty && !submitted); + const touch = () => setDirty(true); + + const existingContacts = destination?.contacts ?? []; + const linkedContact = (hubspotId: string) => + existingContacts.find( + (contact) => + contact.source?.portalId === source.portal.portalId && + contact.source.objectId === hubspotId, + ); + const emailMatch = (email: string) => + email + ? existingContacts.find( + (contact) => contact.id && !contact.source && contact.email === email, + ) + : undefined; + + // When the partner's contacts change under the review, a confirmed match + // or a primary that no longer points at the same person is cleared, so + // the person decides again instead of linking someone else. + const contactsKey = existingContacts + .map((contact) => `${contact.id ?? ""}:${contact.email}`) + .join("|"); + const seenContacts = useRef(contactsKey); + useEffect(() => { + if (seenContacts.current === contactsKey) return; + seenContacts.current = contactsKey; + const stale: string[] = []; + for (const [hubspotId, choice] of Object.entries(contacts)) { + const person = source.contacts.find((c) => c.id === hubspotId); + if ( + choice.match && + choice.match !== "new" && + choice.match !== (person ? emailMatch(person.email)?.id : undefined) + ) + stale.push(hubspotId); + } + if (stale.length) + setContacts((current) => { + const next = { ...current }; + for (const id of stale) { + const choice = next[id]; + if (choice) next[id] = { ...choice, match: "" }; + } + return next; + }); + if ( + primary.startsWith("commerce:") && + !existingContacts.some( + (contact) => `commerce:${contact.id ?? ""}` === primary, + ) + ) + setPrimary(""); + // `contactsKey` captures the contacts the choices refer to. + }, [contactsKey]); + + const rawInput = () => { + operationId.current ??= crypto.randomUUID(); + newPartnerId.current ??= crypto.randomUUID(); + const selectedContacts = Object.entries(contacts).filter( + ([, choice]) => choice.selected, + ); + return { + operationId: operationId.current, + connection, + source: { + companyId: source.company.id, + dealId: source.deal?.reference.id ?? null, + }, + reviewed: source.digests, + destination: destination + ? { + kind: "existing_partner", + partnerId: destination.id, + expectedVersion: destination.version, + // Every decided field, explicitly; an undecided one is left + // out so the server refuses it instead of reading it as "keep". + fields: Object.fromEntries( + hubspotPartnerFields.flatMap((field) => { + const decision = decisions[field]; + return decision ? [[field, decision]] : []; + }), + ), + } + : { + kind: "new_partner", + partnerId: newPartnerId.current, + name, + website, + region, + status, + ownerId, + models, + dismissedSuggestions: suggestions + .filter((suggestion) => dismissed[suggestion.id]?.done) + .map((suggestion) => ({ + partnerId: suggestion.id, + reason: dismissed[suggestion.id]?.reason ?? "", + })), + }, + contacts: selectedContacts.map(([hubspotId, choice]) => ({ + hubspotId, + matchContactId: + choice.match && choice.match !== "new" ? choice.match : null, + })), + primaryContact: primary.startsWith("hubspot:") + ? { kind: "hubspot", id: primary.slice(8) } + : primary.startsWith("commerce:") + ? { kind: "commerce", id: primary.slice(9) } + : null, + includeDeal, + endCustomerCompanyId: includeDeal && endCustomer ? endCustomer : null, + notes, + referenceProperties: references.map((key) => { + const [objectType, property] = key.split(":"); + return { objectType, name: property }; + }), + documents: Object.entries(documents) + .filter(([, choice]) => choice.selected) + .map(([fileId, choice]) => ({ + fileId, + title: choice.title, + kind: choice.kind, + })), + }; + }; + + // The plan the footer describes is the plan the server will build. + const preview = useMemo(() => { + const parsed = HubSpotHandoffInputSchema.safeParse(rawInput()); + if (!parsed.success) return null; + try { + return buildHubSpotPlan({ + source, + handoff: parsed.data, + destination, + history, + ownerName: owners.find((owner) => owner.id === ownerId)?.name ?? null, + newId: () => "00000000-0000-4000-8000-000000000000", + }).plan; + } catch { + return null; + } + // rawInput reads the state listed here. + }, [ + name, + website, + region, + status, + ownerId, + models, + dismissed, + decisions, + contacts, + primary, + includeDeal, + endCustomer, + notes, + references, + documents, + source, + destination, + ]); + + // Everything that must be decided before the commit, each linked to its field. + const issues: { id: string; label: string }[] = []; + if (!destination && !name.trim()) + issues.push({ + id: "handoff-name", + label: t("operations.hubspot.issue.name"), + }); + if (destination) + for (const field of hubspotPartnerFields) + if (decisions[field] === null) + issues.push({ + id: `decision-${field}`, + label: t("operations.hubspot.issue.decision", { + field: t(fieldLabels[field]), + }), + }); + if (!destination) + for (const suggestion of suggestions) + if (!dismissed[suggestion.id]?.done) + issues.push({ + id: `suggestion-${suggestion.id}`, + label: t("operations.hubspot.issue.suggestion", { + name: suggestion.name, + }), + }); + for (const [hubspotId, choice] of Object.entries(contacts)) { + const person = source.contacts.find((c) => c.id === hubspotId); + if ( + choice.selected && + person && + !linkedContact(person.id) && + emailMatch(person.email) && + !choice.match + ) + issues.push({ + id: `contact-match-${hubspotId}`, + label: t("operations.hubspot.issue.contactMatch", { + name: person.name, + }), + }); + } + for (const [fileId, choice] of Object.entries(documents)) + if (choice.selected && !choice.title.trim()) + issues.push({ + id: `document-title-${fileId}`, + label: t("operations.hubspot.issue.documentTitle"), + }); + + const linkOnly = preview ? hubspotPlanIsLinkOnly(preview) : false; + const nothingToDo = alreadyLinked && linkOnly; + const primaryLabel = !destination + ? t("operations.hubspot.action.create") + : linkOnly && !alreadyLinked + ? t("operations.hubspot.action.link") + : t("operations.hubspot.action.import"); + const blocked = Boolean(linkedElsewhere) || !canImport || nothingToDo; + const advancedInEffect = [ + references.length + ? t("operations.hubspot.more.references", { count: references.length }) + : "", + hubspotPartnerFields.some((field) => decisions[field] === "clear") + ? t("operations.hubspot.more.clears") + : "", + ].filter(Boolean); + + function submit() { + setShowErrors(true); + setFailure(null); + setLinkedFirst(null); + if (issues.length) { + requestAnimationFrame(() => summaryRef.current?.focus()); + return; + } + startTransition(async () => { + const result = await commitHubSpotHandoff(rawInput()).catch(() => ({ + ok: false as const, + code: "UNEXPECTED", + })); + if (!result.ok) { + setFailure(result.code); + // Someone changed the partner: load their change and keep every + // choice, so the comparison shows it and asks again where needed. + if ( + result.code === "PARTNER_VERSION_CONFLICT" || + result.code === "HUBSPOT_SIMILAR_PARTNER" + ) + router.refresh(); + requestAnimationFrame(() => summaryRef.current?.focus()); + return; + } + const value = result.value; + if (value.kind === "source_changed") { + // Fresh source, same selections: the page reloads its data only. + setChangedItems(value.items); + router.refresh(); + requestAnimationFrame(() => summaryRef.current?.focus()); + return; + } + if (value.kind === "already_linked") { + setLinkedFirst(value); + requestAnimationFrame(() => summaryRef.current?.focus()); + return; + } + setSubmitted(true); + router.push( + receiptHref(value.partnerId, value.importId, { + start: value.pendingDocuments > 0, + }), + ); + }); + } + + const changedLabel = (key: string) => { + if (key === "company") return t("operations.hubspot.group.company"); + if (key === "deal") return t("operations.hubspot.group.context"); + const [kind, id] = key.split(":"); + if (kind === "contact") + return source.contacts.find((c) => c.id === id)?.name ?? key; + if (kind === "note") return t("operations.hubspot.group.notes"); + return source.files?.find((file) => file.id === id)?.name ?? key; + }; + + const deal = source.deal?.reference ?? null; + const ownerReason = (() => { + const proposedId = + ownerProposal.kind === "none" ? "" : ownerProposal.ownerId; + if (ownerId !== proposedId) return t("operations.hubspot.owner.chosen"); + if (ownerProposal.kind === "mapped") + return t("operations.hubspot.owner.mapped", { + name: ownerProposal.hubspotOwner, + }); + if (ownerProposal.kind === "viewer") + return ownerProposal.hubspotOwner + ? t("operations.hubspot.owner.viewerUnmapped", { + name: ownerProposal.hubspotOwner, + }) + : t("operations.hubspot.owner.viewerNoOwner"); + return ownerProposal.hubspotOwner + ? t("operations.hubspot.owner.noneUnmapped", { + name: ownerProposal.hubspotOwner, + }) + : t("operations.hubspot.owner.none"); + })(); + + const referenceOptions = [ + ...source.companyValues, + ...(source.deal?.values ?? []), + ]; + + return ( +
+
+ + {deal + ? t("operations.hubspot.review.fromDeal", { deal: deal.name }) + : t("operations.hubspot.review.fromCompany")} + + } + link={ + + {t("operations.hubspot.openInHubSpot")} + + } + /> + + + {t("operations.hubspot.review.storedOn")} + + } + link={ + destination ? ( + + {t("operations.hubspot.openPartner")} + + ) : undefined + } + /> +
+ + + {t("operations.hubspot.review.read")}{" "} + + + + {demo ? ( + + ) : !canImport ? ( + + ) : null} + {linkedElsewhere ? ( + + ) : null} + +
+ {changedItems ? ( + + ) : null} + {linkedFirst ? ( + + + {t("operations.hubspot.openPartner")} + + setSubmitted(true)} + > + {t("operations.hubspot.reviewUpdates")} + + + } + /> + ) : null} + {failure ? ( + + ) : null} + {showErrors && issues.length ? ( +
+

+ {t("operations.hubspot.issue.title", { count: issues.length })} +

+ +
+ ) : null} +
+ + {destination ? ( +
+

{t("operations.hubspot.compare.title")}

+

{t("operations.hubspot.compare.body")}

+
+ + + + + + + + + + + {hubspotPartnerFields.map((field) => { + const current = destination[field]; + const hubspot = source.proposed[field]; + const fieldDrift = drift(field); + const decision = decisions[field]; + const options: HubSpotFieldDecision[] = [ + "keep", + "use_hubspot", + ...(field !== "name" && current ? (["clear"] as const) : []), + ]; + return ( + + + + + + + ); + })} + +
+ {t("operations.hubspot.compare.title")} +
{t("operations.hubspot.compare.field")}{t("operations.hubspot.compare.commerce")}{t("operations.hubspot.compare.hubspot")}{t("operations.hubspot.compare.decision")}
+ + {t(fieldLabels[field])} + {fieldDrift !== "never_imported" && + fieldDrift !== "unchanged" ? ( + + {t(driftLabels[fieldDrift])} + + ) : null} + + + {current || ( + + {t("operations.hubspot.notSet")} + + )} + + {hubspot || ( + + {t("operations.hubspot.notSet")} + + )} + +
+ + {t("operations.hubspot.compare.decisionFor", { + field: t(fieldLabels[field]), + })} + + {options.map((option) => ( + + ))} +
+
+

+ {t("operations.hubspot.compare.unchanged")} +

+
+ ) : ( +
+

{t("operations.hubspot.group.company")}

+ {suggestions.length ? ( + + ) : null} + {suggestions.length ? ( +
    + {suggestions.map((suggestion) => { + const state = dismissed[suggestion.id] ?? { + open: false, + reason: "", + done: false, + }; + const set = (patch: Partial) => { + touch(); + setDismissed((all) => ({ + ...all, + [suggestion.id]: { ...state, ...patch }, + })); + }; + return ( +
  • +
    +

    {suggestion.name}

    + + {t( + suggestion.reason === "name" + ? "operations.hubspot.suggestion.sameName" + : "operations.hubspot.suggestion.sameWebsite", + )} + + {state.done ? ( + + {t("operations.hubspot.suggestion.dismissed")} + + ) : null} +
    + {suggestion.website ? ( + + {suggestion.website} + + ) : null} + {suggestion.linkedCompanyId ? ( + + {t("operations.hubspot.suggestion.linkedOther")} + + ) : null} +
    + + {t("operations.hubspot.suggestion.inspect")} + + {suggestion.linkedCompanyId ? null : ( + setSubmitted(true)} + > + {t("operations.hubspot.suggestion.useThis")} + + )} + {state.done ? ( + + ) : state.open ? null : ( + + )} +
    + {state.open && !state.done ? ( +
    + + set({ reason: event.currentTarget.value }) + } + /> +
    + +
    +
    + ) : null} +
  • + ); + })} +
+ ) : null} +
+ { + touch(); + setName(event.currentTarget.value); + }} + help={ + name.trim() !== source.proposed.name + ? t("operations.hubspot.inHubSpot", { + value: + source.proposed.name || t("operations.hubspot.notSet"), + }) + : undefined + } + error={ + showErrors && !name.trim() + ? t("operations.hubspot.issue.name") + : undefined + } + /> + { + touch(); + setWebsite(event.currentTarget.value); + }} + help={ + website.trim() !== source.proposed.website + ? t("operations.hubspot.inHubSpot", { + value: + source.proposed.website || + t("operations.hubspot.notSet"), + }) + : undefined + } + /> + { + touch(); + setRegion(event.currentTarget.value); + }} + help={ + region.trim() !== source.proposed.region + ? t("operations.hubspot.inHubSpot", { + value: + source.proposed.region || + t("operations.hubspot.notSet"), + }) + : undefined + } + /> + { + touch(); + setOwnerId(event.currentTarget.value); + }} + options={[ + { value: "", label: t("operations.partners.unowned") }, + ...owners.map((owner) => ({ + value: owner.id, + label: owner.name, + })), + ]} + /> +
+ + {t("operations.hubspot.models")} + + {partnerModels.map((model) => ( + + ))} +
+
+
+ )} + +
+ {source.contacts.length ? ( +
    + {source.contacts.map((person) => { + const choice = contacts[person.id] ?? { + selected: false, + match: "", + }; + const linked = linkedContact(person.id); + const match = linked ? undefined : emailMatch(person.email); + const set = (patch: Partial) => { + touch(); + setContacts((all) => ({ + ...all, + [person.id]: { ...choice, ...patch }, + })); + }; + return ( +
  • + { + const selected = event.currentTarget.checked; + set({ selected }); + if (!selected && primary === `hubspot:${person.id}`) + setPrimary(""); + }} + description={ + [ + person.role, + person.email, + person.labels.length + ? t("operations.hubspot.people.labels", { + labels: person.labels.join(", "), + }) + : "", + ] + .filter(Boolean) + .join(" · ") || undefined + } + /> + {linked ? ( + + {t("operations.hubspot.people.alreadyLinked", { + name: linked.name, + })} + + ) : null} + {match && choice.selected ? ( +
    + { + touch(); + setPrimary(event.currentTarget.value); + }} + options={[ + { + value: "", + label: t("operations.hubspot.people.primaryUnchanged"), + }, + // A person already on the partner, or confirmed as the same + // person as a Commerce contact, is offered once, as that + // Commerce contact; only people who will be added appear by + // their HubSpot entry. + ...source.contacts + .filter((person) => { + const choice = contacts[person.id]; + return ( + choice?.selected && + !linkedContact(person.id) && + (!choice.match || choice.match === "new") + ); + }) + .map((person) => ({ + value: `hubspot:${person.id}`, + label: person.name, + })), + ...existingContacts + .filter((contact) => contact.id) + .map((contact) => ({ + value: `commerce:${contact.id ?? ""}`, + label: t("operations.hubspot.people.existing", { + name: contact.name, + }), + })), + ]} + /> + ) : null} +
+ +
+ {deal ? ( +
+ { + touch(); + setIncludeDeal(event.currentTarget.checked); + if (!event.currentTarget.checked) setEndCustomer(""); + }} + description={t("operations.hubspot.context.keepDealHelp")} + /> +
+
{t("operations.hubspot.deal.stage")}
+
+ {[deal.pipeline.label, deal.stage.label] + .filter(Boolean) + .join(" · ") || t("operations.hubspot.notSet")} +
+
{t("operations.hubspot.deal.amount")}
+
+ {deal.amount && deal.currency + ? formatHubSpotAmount(deal.amount, deal.currency, locale) + : deal.amount + ? t("operations.hubspot.deal.amountNoCurrency", { + amount: deal.amount, + }) + : t("operations.hubspot.notSet")} +
+
{t("operations.hubspot.deal.close")}
+
+ {formatHubSpotDate(deal.closeDate, locale) ?? + t("operations.hubspot.notSet")} +
+
{t("operations.hubspot.deal.owner")}
+
+ {deal.owner?.name ?? + deal.owner?.id ?? + t("operations.hubspot.noOwner")} +
+
{t("operations.hubspot.deal.companies")}
+
+
    + {deal.associatedCompanies.map((company) => ( +
  • + + {company.name} + {company.id === source.company.id + ? ` · ${t("operations.hubspot.deal.becomesPartner")}` + : ""} + + {company.labels.length ? ( + + {company.labels.map((label) => ( + {label} + ))} + + ) : null} +
  • + ))} +
+
+
+ {includeDeal && + deal.associatedCompanies.some((c) => c.id !== source.company.id) ? ( +
+ + set({ title: event.currentTarget.value }) + } + error={ + showErrors && !choice.title.trim() + ? t("operations.hubspot.issue.documentTitle") + : undefined + } + /> + setPipelineId(event.currentTarget.value)} + options={[ + { + value: "", + label: t("operations.hubspot.settings.pipelineFirst"), + }, + ...catalog.pipelines + .filter((pipeline) => scope.pipelines.includes(pipeline.id)) + .map((pipeline) => ({ + value: pipeline.id, + label: pipeline.label, + })), + ]} + /> +
+
+ {catalog.owners.map((owner) => ( + check(event.currentTarget.value)} + options={[ + { value: "", label: t("operations.hubspot.register.chooseNone") }, + ...suggestion.choices.map((name) => ({ + value: name, + label: name, + })), + ]} + /> + ) : null} diff --git a/apps/web/src/features/internal-ops/partners/partner-detail.tsx b/apps/web/src/features/internal-ops/partners/partner-detail.tsx index b795995c..79ba9938 100644 --- a/apps/web/src/features/internal-ops/partners/partner-detail.tsx +++ b/apps/web/src/features/internal-ops/partners/partner-detail.tsx @@ -97,11 +97,33 @@ function historyLine(entry: PartnerActivity, t: Translator): string { const client = entry.dealEndClient ?? ""; switch (entry.eventType) { case "partner.created": - return t("operations.partners.history.created", { actor }); + return entry.source === "hubspot" + ? t("operations.partners.history.createdFromHubSpot", { actor }) + : t("operations.partners.history.created", { actor }); case "partner.updated": - return t("operations.partners.history.updated", { + return t( + entry.source === "hubspot" + ? "operations.partners.history.updatedFromHubSpot" + : "operations.partners.history.updated", + { actor, fields: changedFields(entry, t) }, + ); + case "hubspot.imported": + return t("operations.partners.history.hubspotImported", { actor }); + case "hubspot.document_attached": + return t("operations.partners.history.hubspotDocumentAttached", { + actor, + }); + case "hubspot.document_failed": + return t("operations.partners.history.hubspotDocumentFailed", { actor }); + case "hubspot.document_duplicate": + return t("operations.partners.history.hubspotDocumentDuplicate", { + actor, + }); + case "hubspot.document_reused": + return t("operations.partners.history.hubspotDocumentReused", { actor }); + case "hubspot.document_excluded": + return t("operations.partners.history.hubspotDocumentExcluded", { actor, - fields: changedFields(entry, t), }); case "partner.deal_registered": return t("operations.partners.history.dealRegistered", { actor, client }); @@ -205,6 +227,7 @@ function Deals({ canEdit, protectionDays, organizations, + dealSuggestion, }: { partner: PartnerRecord; deals: readonly PartnerDealWithConflicts[]; @@ -214,9 +237,18 @@ function Deals({ canEdit: boolean; protectionDays: number; organizations: readonly PartnerOrganizationOption[]; + dealSuggestion: { + endClient: string; + notes: string; + choices?: readonly string[]; + } | null; }) { return ( -
+

{t("operations.partners.deals.title")}

{t("operations.partners.deals.description")} @@ -228,6 +260,7 @@ function Deals({ today={today} protectionDays={protectionDays} organizations={organizations} + suggestion={dealSuggestion} /> ) : null} {deals.length === 0 ? ( @@ -424,6 +457,9 @@ export function PartnerDetail({ canEdit, demo, documents, + hubspot, + receipt, + dealSuggestion = null, }: { t: Translator; locale: string; @@ -437,6 +473,15 @@ export function PartnerDetail({ canEdit: boolean; demo: boolean; documents?: ReactNode; + /** The HubSpot section and a just-finished import's receipt. */ + hubspot?: ReactNode; + receipt?: ReactNode; + /** A registration prepared from a HubSpot handoff, still unsaved. */ + dealSuggestion?: { + endClient: string; + notes: string; + choices?: readonly string[]; + } | null; }) { const overdue = partner.nextStepDue !== null && partner.nextStepDue < today; const overview: [string, ReactNode][] = [ @@ -540,7 +585,9 @@ export function PartnerDetail({ {demo ? ( ) : null} - {documents} + {receipt} + {documents ?

{documents}
: null} + {hubspot}
@@ -559,6 +606,7 @@ export function PartnerDetail({ canEdit={canEdit} protectionDays={protectionDays} organizations={organizations} + dealSuggestion={dealSuggestion} />
@@ -569,8 +617,23 @@ export function PartnerDetail({ {partner.contacts.length ? (