From 86cad46705ee62f458d5f5d73f74a5f8353375b7 Mon Sep 17 00:00:00 2001 From: James Kurz Date: Thu, 22 Oct 2026 20:30:29 -0400 Subject: [PATCH 1/2] Let staff remove mistaken documents with an audited, reversible removal; recommend HubSpot service keys Co-Authored-By: Claude Opus 5.5 --- .env.example | 4 +- .../internal-ops/account-documents/actions.ts | 61 +++++ .../account-documents/documents.module.css | 50 ++++ .../account-documents/documents.test.tsx | 93 ++++++- .../account-documents/documents.tsx | 246 +++++++++++++++++- .../account-documents/section.tsx | 7 +- .../features/internal-ops/hubspot/actions.ts | 6 +- .../internal-ops/hubspot/hubspot.test.tsx | 1 + .../features/internal-ops/hubspot/receipt.tsx | 13 +- .../internal-ops/hubspot/setup-guide.tsx | 9 +- .../messages/operations-account-documents.ts | 41 ++- .../src/i18n/messages/operations-hubspot.ts | 16 +- docs/operations/hubspot-handoff.md | 23 +- docs/operations/partner-records.md | 20 ++ packages/contracts/src/account-documents.ts | 29 +++ packages/contracts/src/hubspot.ts | 2 + .../account-documents.integration.test.ts | 75 ++++++ .../db/src/repositories/account-documents.ts | 194 +++++++++++++- packages/db/src/repositories/hubspot.ts | 108 +++++--- packages/db/src/schema/account-documents.ts | 10 +- .../001484_account_document_removal.sql | 79 ++++++ .../1484_account_document_removal.test.sql | 55 ++++ 22 files changed, 1070 insertions(+), 72 deletions(-) create mode 100644 apps/web/src/features/internal-ops/account-documents/actions.ts create mode 100644 supabase/migrations/001484_account_document_removal.sql create mode 100644 supabase/tests/1484_account_document_removal.test.sql diff --git a/.env.example b/.env.example index a019c077..199f5e01 100644 --- a/.env.example +++ b/.env.example @@ -96,8 +96,8 @@ 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 +# HubSpot handoff and pipeline (docs/operations/hubspot-handoff.md): a HubSpot +# service key (or legacy 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= diff --git a/apps/web/src/features/internal-ops/account-documents/actions.ts b/apps/web/src/features/internal-ops/account-documents/actions.ts new file mode 100644 index 00000000..666306c2 --- /dev/null +++ b/apps/web/src/features/internal-ops/account-documents/actions.ts @@ -0,0 +1,61 @@ +"use server"; + +import { z } from "zod"; + +import { + AccountDocumentTargetSchema, + type AccountDocumentRecord, + type RemovedAccountDocumentRecord, +} from "@clockwork/contracts"; + +import { SessionExpiredError } from "@/src/auth/session"; + +import { attempt, type ActionResult } from "../contracts/action-result"; +import { contractActor } from "../contracts/server"; +import { accountDocumentRepository, accountDocumentStaff } 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"); + throw error; + } + }); +} + +const RequestSchema = z + .object({ target: AccountDocumentTargetSchema }) + .passthrough(); + +/** + * Takes a document off its record, with the reason. Who may attach may + * remove: the record's read permission plus `contract:write`, checked on a + * real staff session with MFA. The file and its history are kept. + */ +export async function removeAccountDocument(raw: unknown) { + return run(async (): Promise => { + const { target, ...input } = RequestSchema.parse(raw); + const session = await accountDocumentStaff(target, true); + return accountDocumentRepository().remove( + target, + input, + contractActor(session), + ); + }); +} + +/** Puts a removed document back on its record. */ +export async function restoreAccountDocument(raw: unknown) { + return run(async (): Promise => { + const { target, ...input } = RequestSchema.parse(raw); + const session = await accountDocumentStaff(target, true); + return accountDocumentRepository().restore( + target, + input, + contractActor(session), + ); + }); +} diff --git a/apps/web/src/features/internal-ops/account-documents/documents.module.css b/apps/web/src/features/internal-ops/account-documents/documents.module.css index d012638d..9be8a013 100644 --- a/apps/web/src/features/internal-ops/account-documents/documents.module.css +++ b/apps/web/src/features/internal-ops/account-documents/documents.module.css @@ -27,6 +27,56 @@ align-items: center; gap: var(--cw-space-2); } +.notice:focus { + outline: none; +} +.notice:empty { + display: none; +} +/* The confirmation sits under the row it removes, across its full width. */ +.removal { + display: grid; + grid-column: 1 / -1; + gap: var(--cw-space-3); + margin-top: var(--cw-space-2); + padding: var(--cw-space-3); + border: 1px solid var(--cw-danger-border, var(--cw-border)); + border-radius: var(--cw-radius-sm); + background: var(--cw-danger-soft, var(--cw-surface)); +} +.removalText { + margin: 0; + color: var(--cw-ink); +} +.removalActions { + display: flex; + flex-wrap: wrap; + gap: var(--cw-space-2); +} +.history { + margin-top: var(--cw-space-4); + border-top: 1px solid var(--cw-border); + padding-top: var(--cw-space-3); +} +.history > summary { + cursor: pointer; + color: var(--cw-muted); + font-weight: 600; +} +.historyList { + display: grid; + gap: var(--cw-space-3); + margin: var(--cw-space-3) 0 0; + padding: 0; + list-style: none; +} +.historyList > li { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--cw-space-3); +} @media (max-width: 48rem) { .fileRow { display: grid; diff --git a/apps/web/src/features/internal-ops/account-documents/documents.test.tsx b/apps/web/src/features/internal-ops/account-documents/documents.test.tsx index 3e7714c0..7ff518ea 100644 --- a/apps/web/src/features/internal-ops/account-documents/documents.test.tsx +++ b/apps/web/src/features/internal-ops/account-documents/documents.test.tsx @@ -1,10 +1,19 @@ import { renderToString } from "react-dom/server"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, expect, it, vi } from "vitest"; -const mocks = vi.hoisted(() => ({ refresh: vi.fn(), fetch: vi.fn() })); +const mocks = vi.hoisted(() => ({ + refresh: vi.fn(), + fetch: vi.fn(), + remove: vi.fn(), + restore: vi.fn(), +})); vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: mocks.refresh }), })); +vi.mock("./actions", () => ({ + removeAccountDocument: mocks.remove, + restoreAccountDocument: mocks.restore, +})); import { AccountDocuments } from "./documents"; const target = { type: "partner" as const, @@ -19,6 +28,14 @@ const file = { sizeBytes: 1024, uploadedByName: "Iris", createdAt: "2026-10-20T12:00:00Z", + version: 1, +}; +const removedFile = { + ...file, + version: 2, + removedAt: "2026-10-21T12:00:00Z", + removedByName: "Iris", + removalReason: "Attached to the wrong record", }; const choose = (name = "Signed MNDA.pdf", type = "application/pdf") => fireEvent.change(screen.getByLabelText("Choose PDF"), { @@ -100,3 +117,77 @@ it("keeps the file picker disabled until its client handler is ready", () => { container.innerHTML = markup; expect(container.querySelector('input[type="file"]')).toBeDisabled(); }); + +it("removes a document only with a reason, then offers undo", async () => { + mocks.remove.mockResolvedValue({ ok: true, value: removedFile }); + mocks.restore.mockResolvedValue({ ok: true, value: { ...file, version: 3 } }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Remove External MNDA" })); + const confirm = screen.getByRole("button", { name: "Remove document" }); + expect(confirm).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Why remove it?"), { + target: { value: "Attached to the wrong record" }, + }); + fireEvent.click(confirm); + await screen.findByText("“External MNDA” was removed from this record."); + expect(mocks.remove).toHaveBeenCalledWith({ + target, + id: file.id, + expectedVersion: 1, + reason: "Attached to the wrong record", + }); + expect( + screen.queryByRole("link", { name: "Download External MNDA" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("1 removed document")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + await screen.findByText("“External MNDA” is back on this record."); + expect(mocks.restore).toHaveBeenCalledWith({ + target, + id: file.id, + expectedVersion: 2, + }); + expect( + screen.getByRole("link", { name: "Download External MNDA" }), + ).toBeInTheDocument(); +}); + +it("lists removed documents with who and why, and no way to open them", () => { + render( + , + ); + expect( + screen.getByText(/Removed by Iris: Attached to the wrong record/), + ).toBeInTheDocument(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Restore External MNDA" }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Remove/ }), + ).not.toBeInTheDocument(); +}); + +it("explains a removal someone else already changed", async () => { + mocks.remove.mockResolvedValue({ + ok: false, + code: "ATTACHMENT_VERSION_CONFLICT", + }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Remove External MNDA" })); + fireEvent.change(screen.getByLabelText("Why remove it?"), { + target: { value: "Replaced" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Remove document" })); + expect( + await screen.findByText(/Someone else changed this document/), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Download External MNDA" }), + ).toBeInTheDocument(); +}); diff --git a/apps/web/src/features/internal-ops/account-documents/documents.tsx b/apps/web/src/features/internal-ops/account-documents/documents.tsx index 245f4d19..9bfde96d 100644 --- a/apps/web/src/features/internal-ops/account-documents/documents.tsx +++ b/apps/web/src/features/internal-ops/account-documents/documents.tsx @@ -8,6 +8,7 @@ import { type AccountDocumentKind, type AccountDocumentRecord, type AccountDocumentTarget, + type RemovedAccountDocumentRecord, } from "@clockwork/contracts"; import { Button, @@ -29,6 +30,7 @@ import { import { localFileProblem, postDocumentForm } from "../contracts/upload-client"; import { LocalTimestamp } from "../local-timestamp"; import { SessionExpiredReload } from "../session-expiry"; +import { removeAccountDocument, restoreAccountDocument } from "./actions"; import styles from "../contracts/contracts.module.css"; import attachmentStyles from "./documents.module.css"; @@ -48,16 +50,20 @@ const errors: Record = { ATTACHMENT_NOT_FOUND: "operations.accountDocuments.missing", CONTRACT_FORBIDDEN: "operations.accountDocuments.forbidden", INVALID_INPUT: "operations.accountDocuments.invalid", + ATTACHMENT_VERSION_CONFLICT: "operations.accountDocuments.changed", }; export function AccountDocuments({ target, files, + removed = [], canWrite, demo = false, }: { target: AccountDocumentTarget; files: readonly AccountDocumentRecord[]; + /** Documents taken off this record, kept as history. */ + removed?: readonly RemovedAccountDocumentRecord[]; canWrite: boolean; demo?: boolean; }) { @@ -78,10 +84,96 @@ export function AccountDocuments({ const [notice, setNotice] = useState(null); // Keep the new row visible immediately, even while the server refresh runs. const [added, setAdded] = useState([]); + // Removals and restores show at once too, ahead of the refresh. + const [removedNow, setRemovedNow] = useState( + [], + ); + const [restoredNow, setRestoredNow] = useState([]); + const [removing, setRemoving] = useState<{ + id: string; + reason: string; + } | null>(null); + const [undo, setUndo] = useState(null); + const noticeRef = useRef(null); + const gone = new Set( + removedNow.filter((row) => !restoredNow.includes(row.id)).map((r) => r.id), + ); const visible = [ ...added.filter((row) => !files.some((f) => f.id === row.id)), ...files, - ]; + ].filter((row) => !gone.has(row.id)); + const history = [ + ...removedNow, + ...removed.filter((row) => !removedNow.some((r) => r.id === row.id)), + ].filter( + (row) => + !restoredNow.includes(row.id) || + // Removed again after a restore on this page. + gone.has(row.id), + ); + const announce = () => + requestAnimationFrame(() => noticeRef.current?.focus()); + async function remove(document: AccountDocumentRecord, reason: string) { + setBusy(true); + setError(null); + setNotice(null); + try { + const result = await removeAccountDocument({ + target, + id: document.id, + expectedVersion: document.version, + reason, + }).catch(() => ({ ok: false as const, code: "UNEXPECTED" })); + if (!result.ok) { + setError(result.code); + return; + } + setRemoving(null); + setRestoredNow((ids) => ids.filter((id) => id !== document.id)); + setRemovedNow((rows) => [ + result.value, + ...rows.filter((row) => row.id !== document.id), + ]); + setUndo(result.value); + setNotice( + t("operations.accountDocuments.removed", { name: document.title }), + ); + announce(); + router.refresh(); + } finally { + setBusy(false); + } + } + async function restore(document: RemovedAccountDocumentRecord) { + setBusy(true); + setError(null); + setNotice(null); + try { + const result = await restoreAccountDocument({ + target, + id: document.id, + expectedVersion: document.version, + }).catch(() => ({ ok: false as const, code: "UNEXPECTED" })); + if (!result.ok) { + setError(result.code); + return; + } + setUndo(null); + setRestoredNow((ids) => [...ids, document.id]); + setRemovedNow((rows) => rows.filter((row) => row.id !== document.id)); + setAdded((rows) => [ + result.value, + ...rows.filter((row) => row.id !== document.id), + ]); + setNotice( + t("operations.accountDocuments.restored", { name: document.title }), + ); + announce(); + router.refresh(); + } finally { + setBusy(false); + } + } const base = `/internal/record-documents/${target.type}/${target.id}`; function changed() { requestId.current = null; @@ -159,9 +251,30 @@ export function AccountDocuments({ : {})} /> ) : null} - {notice ? ( - - ) : null} +
+ {notice ? ( + void restore(undo)} + > + {t("operations.accountDocuments.undo")} + + ), + } + : {})} + /> + ) : null} +
{demo ? ( {t("operations.accountDocuments.download")} + {canWrite && !demo ? ( + + ) : null} + {removing?.id === document.id ? ( +
{ + event.preventDefault(); + if (removing.reason.trim()) + void remove(document, removing.reason.trim()); + }} + > +

+ {t("operations.accountDocuments.removeConfirm", { + name: document.title, + })} +

+ + setRemoving({ + id: document.id, + reason: event.currentTarget.value, + }) + } + /> + + + + +
+ ) : null} ))} )} + {history.length && !demo ? ( +
+ + {t("operations.accountDocuments.removedTitle", { + count: history.length, + })} + +

+ {t("operations.accountDocuments.removedBody")} +

+
    + {history.map((document) => ( +
  • + + {document.title} + + {document.fileName} ·{" "} + {formatFileSize(document.sizeBytes, locale)} + + + {t("operations.accountDocuments.removedBy", { + name: document.removedByName, + reason: document.removalReason, + })}{" "} + ·{" "} + + + + {canWrite ? ( + + ) : null} +
  • + ))} +
+
+ ) : null} {canWrite && !demo ? (
); diff --git a/apps/web/src/features/internal-ops/hubspot/actions.ts b/apps/web/src/features/internal-ops/hubspot/actions.ts index b4c7ae48..4f9f03bd 100644 --- a/apps/web/src/features/internal-ops/hubspot/actions.ts +++ b/apps/web/src/features/internal-ops/hubspot/actions.ts @@ -192,7 +192,11 @@ export async function processHubSpotDocument(raw: unknown) { 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)) + // A duplicate whose identical copy was since removed can be attached. + if ( + !["pending", "failed"].includes(item.status) && + !(item.status === "duplicate" && item.existingAttachment === null) + ) throw new Error("HUBSPOT_ITEM_RESOLVED"); // A previous attempt attached it but its answer was lost. const already = await repository.attachmentOnPartner(partnerId, item.id); diff --git a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx index 4ecf6ed7..fb7cf4d1 100644 --- a/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx +++ b/apps/web/src/features/internal-ops/hubspot/hubspot.test.tsx @@ -608,6 +608,7 @@ describe("HubSpotReceipt", () => { errorCode: status === "failed" ? "HUBSPOT_DOCUMENT_UNAVAILABLE" : null, attachmentId: null, existingAttachment: null, + attachmentRemoved: false, excludedReason: null, attempts: status === "failed" ? 1 : 0, updatedAt: now.toISOString(), diff --git a/apps/web/src/features/internal-ops/hubspot/receipt.tsx b/apps/web/src/features/internal-ops/hubspot/receipt.tsx index 38776e33..b2e7dfd3 100644 --- a/apps/web/src/features/internal-ops/hubspot/receipt.tsx +++ b/apps/web/src/features/internal-ops/hubspot/receipt.tsx @@ -406,6 +406,16 @@ export function HubSpotReceipt({ })} ) : null} + {item.status === "duplicate" && !item.existingAttachment ? ( + + {t("operations.hubspot.receipt.duplicateRemoved")} + + ) : null} + {item.attachmentRemoved ? ( + + {t("operations.hubspot.receipt.attachmentRemoved")} + + ) : null} {item.status === "excluded" && item.excludedReason ? ( {t("operations.hubspot.receipt.excludedBecause", { @@ -461,7 +471,8 @@ export function HubSpotReceipt({ ) : (
- {item.status === "duplicate" ? ( + {item.status === "duplicate" && + item.existingAttachment ? ( diff --git a/apps/web/src/i18n/messages/operations-account-documents.ts b/apps/web/src/i18n/messages/operations-account-documents.ts index 8e7fec0b..def030c5 100644 --- a/apps/web/src/i18n/messages/operations-account-documents.ts +++ b/apps/web/src/i18n/messages/operations-account-documents.ts @@ -49,7 +49,7 @@ export const accountDocumentMessages = defineStaffMessages({ "operations.accountDocuments.remove": { en: "Remove" }, "operations.accountDocuments.removeNamed": { en: "Remove {name}" }, "operations.accountDocuments.removeConfirm": { - en: "Remove “{name}” from this record? It will no longer be listed or openable here. Commerce keeps the file and this change in the record’s history, and you can restore it.", + en: "Remove “{name}” from this record? It will no longer be listed or openable here. Commerce keeps the file and this change in the record’s history; you can restore it unless the same PDF has been attached again.", }, "operations.accountDocuments.removeReason": { en: "Why remove it?" }, "operations.accountDocuments.removeReasonHelp": { diff --git a/apps/web/src/i18n/messages/operations-hubspot.ts b/apps/web/src/i18n/messages/operations-hubspot.ts index 2da310b3..bc478abd 100644 --- a/apps/web/src/i18n/messages/operations-hubspot.ts +++ b/apps/web/src/i18n/messages/operations-hubspot.ts @@ -769,6 +769,9 @@ export const hubspotMessages = defineStaffMessages({ "operations.hubspot.state.noPipelinesBody": { en: "An administrator chooses which deal pipelines staff may see on the HubSpot connection page.", }, + "operations.hubspot.error.HUBSPOT_DOCUMENT_REMOVED": { + en: "The identical copy was removed from the partner meanwhile. Attach this document instead, or exclude it.", + }, "operations.hubspot.error.HUBSPOT_SIMILAR_PARTNER": { en: "A partner that looks like this company was added meanwhile. It is now listed above; inspect it, then create the partner.", }, diff --git a/packages/contracts/src/hubspot.ts b/packages/contracts/src/hubspot.ts index 7454336b..66e19c50 100644 --- a/packages/contracts/src/hubspot.ts +++ b/packages/contracts/src/hubspot.ts @@ -1012,6 +1012,7 @@ export const hubspotErrorCodes = [ "HUBSPOT_CONNECTION_VERSION_CONFLICT", "HUBSPOT_NO_PIPELINES", "HUBSPOT_SIMILAR_PARTNER", + "HUBSPOT_DOCUMENT_REMOVED", ] as const; export type HubSpotErrorCode = (typeof hubspotErrorCodes)[number]; diff --git a/packages/db/src/repositories/account-documents.ts b/packages/db/src/repositories/account-documents.ts index fe02df9a..41e74f8e 100644 --- a/packages/db/src/repositories/account-documents.ts +++ b/packages/db/src/repositories/account-documents.ts @@ -112,7 +112,7 @@ export class AccountDocumentRepository { .from(accountDocuments) .where(and(scope(target), isNotNull(accountDocuments.removedAt))) .orderBy(desc(accountDocuments.removedAt), desc(accountDocuments.id)) - .limit(100) + .limit(500) ).map(removedView); }); } @@ -154,7 +154,9 @@ export class AccountDocumentRepository { .set({ removedAt: new Date(), removedById: actor.id, - removedByName: actor.display ?? actor.id, + // The database keeps a 1–200 character name; an empty or very + // long profile name never blocks a removal. + removedByName: (actor.display ?? "").trim().slice(0, 200) || actor.id, removalReason: input.reason, version: row.version + 1, }) diff --git a/packages/db/src/repositories/hubspot.integration.test.ts b/packages/db/src/repositories/hubspot.integration.test.ts index 35d8b0b3..0a0a6887 100644 --- a/packages/db/src/repositories/hubspot.integration.test.ts +++ b/packages/db/src/repositories/hubspot.integration.test.ts @@ -431,6 +431,83 @@ describe("HubSpot handoff", () => { ).resolves.toBeNull(); }); + it("never reuses or counts a document that was removed from the partner", async () => { + const companyId = "910007"; + const name = `Removed copy ${tag}`; + const plan = newPlan(name, { + documents: ["8201", "8202"].map((fileId) => ({ + fileId, + noteId: "6001", + fileName: `${fileId}.pdf`, + title: `File ${fileId}`, + kind: "other" as const, + size: 100, + updatedAt: null, + })), + }); + await repo.commit( + { + plan, + snapshot: snapshot(companyId, name), + requestDigest: digest(10), + connection, + companyId, + dealId: null, + }, + seller, + ); + const partnerId = plan.destination.partnerId; + const target = { type: "partner" as const, id: partnerId }; + const [first, second] = (await repo.import(partnerId, plan.operationId)) + .items; + if (!first || !second) throw new Error("items missing"); + const bytes = Buffer.from(`%PDF-1.7\nremoved-${tag}\n%%EOF`); + const attachment = await documents.add( + target, + { id: first.id, title: first.title, kind: "other" }, + { fileName: first.fileName, bytes }, + seller, + ); + await repo.recordItem( + partnerId, + plan.operationId, + first.id, + { status: "attached", attachmentId: attachment.id }, + seller, + ); + await repo.recordItem( + partnerId, + plan.operationId, + second.id, + { status: "duplicate", existingAttachmentId: attachment.id }, + seller, + ); + await documents.remove( + target, + { id: attachment.id, expectedVersion: 1, reason: "Wrong file" }, + seller, + ); + const record = await repo.import(partnerId, plan.operationId); + expect(record.items.find((i) => i.id === first.id)).toMatchObject({ + status: "attached", + attachmentRemoved: true, + }); + expect(record.items.find((i) => i.id === second.id)).toMatchObject({ + status: "duplicate", + existingAttachment: null, + }); + await expect( + repo.reuseExisting(partnerId, plan.operationId, second.id, seller), + ).rejects.toThrow("HUBSPOT_DOCUMENT_REMOVED"); + const { createHash } = await import("node:crypto"); + await expect( + repo.attachmentByHash( + partnerId, + createHash("sha256").update(bytes).digest("hex"), + ), + ).resolves.toBeNull(); + }); + it("stops committing reviews read through a connection that changed", async () => { const companyId = "910006"; const name = `Stale ${tag}`; diff --git a/packages/db/src/repositories/hubspot.ts b/packages/db/src/repositories/hubspot.ts index 73078d44..9930efd2 100644 --- a/packages/db/src/repositories/hubspot.ts +++ b/packages/db/src/repositories/hubspot.ts @@ -916,7 +916,7 @@ export class HubSpotRepository { isNull(accountDocuments.removedAt), ), ); - if (!existing) throw new Error("HUBSPOT_DOCUMENT_UNAVAILABLE"); + if (!existing) throw new Error("HUBSPOT_DOCUMENT_REMOVED"); return { values: { status: "reused",