Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/terraform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down
63 changes: 63 additions & 0 deletions apps/web/app/(experience)/(internal)/internal/hubspot/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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<Record<string, string | string[] | undefined>>;
}) {
const [t, loaded] = await Promise.all([
getTranslations(),
searchParams.then(loadPicker),
]);
if (loaded.kind !== "ready")
return (
<HubSpotPageState
state={loaded}
title={t("operations.hubspot.picker.title")}
/>
);
// 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 (
<HubSpotPicker
t={t}
q={loaded.q}
deal={loaded.deal}
partner={loaded.partner}
result={loaded.result}
portalId={loaded.portalId}
canImport={loaded.canImport}
demo={loaded.demo}
/>
);
}

export default withStaffPermission("sales:read", Page);
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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<Record<string, string | string[] | undefined>>;
}) {
const [t, loaded] = await Promise.all([
getTranslations(),
searchParams.then(loadReview),
]);
if (loaded.kind !== "ready")
return (
<HubSpotPageState
state={loaded}
title={t("operations.hubspot.review.title")}
/>
);
return <ReviewPage t={t} loaded={loaded} />;
}

export default withStaffPermission("sales:read", Page);
10 changes: 9 additions & 1 deletion apps/web/app/(experience)/(internal)/internal/mndas/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Metadata> {
Expand All @@ -21,11 +22,18 @@ export default async function Page({
const query = parseMndaRegisterParams(params);
const result = await loadMndaPage(query);
if (!result.ok) return <MndaAccessState code={result.code} />;
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 (
<MndaWorkspace
initial={result.value}
initialQuery={query}
compose={params.compose === "1"}
compose={compose}
prefill={prefill}
/>
);
}
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<HubSpotPageState
state={loaded}
title={t("operations.hubspot.settings.title")}
/>
);
return (
<main className={styles.main} id="main-content">
<Breadcrumbs
label={breadcrumbsLabel(t)}
items={[
{
label: t("operations.hubspot.settings.owner"),
href: "/internal/owner",
},
{ label: t("operations.hubspot.settings.title") },
]}
renderLink={(href, label) => <Link href={href as Route}>{label}</Link>}
/>
<PageHeader
title={t("operations.hubspot.settings.title")}
description={t("operations.hubspot.settings.description")}
/>
{loaded.demo ? null : (
<HubSpotSetupGuide
t={t}
stage={hubspotSetupStage({
tokenConfigured: Boolean(loaded.portal ?? loaded.portalError),
portalReachable: Boolean(loaded.portal),
active: Boolean(
loaded.active &&
loaded.portal &&
loaded.active.portalId === loaded.portal.portalId,
),
})}
/>
)}
<HubSpotSettings
portal={loaded.portal}
portalError={loaded.portalError ?? loaded.catalogError}
active={loaded.active}
latest={loaded.latest}
catalog={loaded.catalog}
staff={loaded.staff}
canManage={loaded.canManage}
/>
</main>
);
}

export default withStaffPermission("staff:manage", Page);
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<Record<string, string | string[] | undefined>>;
}) {
const [{ id }, query] = await Promise.all([params, searchParams]);
const [t, locale, loaded] = await Promise.all([
getTranslations(),
getFormattingLocale(),
Expand All @@ -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 (
<PartnerDetail
t={t}
Expand All @@ -51,6 +61,26 @@ async function Page({ params }: { params: Promise<{ id: string }> }) {
target={{ type: "partner", id: loaded.partner.id }}
/>
}
hubspot={
<PartnerHubSpotSection
t={t}
locale={locale}
partnerId={loaded.partner.id}
view={hubspot.view}
canEdit={loaded.canEdit}
/>
}
receipt={
hubspot.receipt ? (
<HubSpotReceipt
record={hubspot.receipt}
canEdit={loaded.canEdit}
autoStart={hubspot.autoStart}
focus
/>
) : null
}
dealSuggestion={loaded.canEdit ? hubspot.dealSuggestion : null}
partner={loaded.partner}
deals={loaded.deals}
activity={loaded.activity}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ async function Page({
today={loaded.today}
canEdit={loaded.canEdit}
demo={loaded.demo}
hubspot={loaded.hubspot}
/>
);
}
Expand Down
48 changes: 48 additions & 0 deletions apps/web/app/(experience)/(internal)/internal/pipeline/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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<Record<string, string | string[] | undefined>>;
}) {
const [t, locale, loaded] = await Promise.all([
getTranslations(),
getFormattingLocale(),
searchParams.then(loadPipelinePage),
]);
if (loaded.kind !== "ready")
return (
<HubSpotPageState
state={loaded}
title={t("operations.hubspot.pipeline.title")}
/>
);
return (
<PipelinePage
t={t}
locale={locale}
query={loaded.query}
view={loaded.view}
selected={loaded.selected}
people={loaded.people}
portal={loaded.portal}
demo={loaded.demo}
/>
);
}

export default withStaffPermission("sales:read", Page);
8 changes: 8 additions & 0 deletions apps/web/app/(experience)/channel-surfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ const expected: Readonly<Record<ExperienceAudience, Classification>> = {
"/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",
Expand All @@ -321,12 +325,16 @@ const expected: Readonly<Record<ExperienceAudience, Classification>> = {
"/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",
Expand Down
Loading
Loading