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: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/features/internal-ops/account-documents/actions.ts
Original file line number Diff line number Diff line change
@@ -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<T>(operation: () => Promise<T>): Promise<ActionResult<T>> {
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<RemovedAccountDocumentRecord> => {
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<AccountDocumentRecord> => {
const { target, ...input } = RequestSchema.parse(raw);
const session = await accountDocumentStaff(target, true);
return accountDocumentRepository().restore(
target,
input,
contractActor(session),
);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"), {
Expand Down Expand Up @@ -100,3 +117,122 @@ 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(<AccountDocuments target={target} files={[file]} canWrite />);
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(
<AccountDocuments
target={target}
files={[]}
removed={[removedFile]}
canWrite={false}
/>,
);
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(<AccountDocuments target={target} files={[file]} canWrite />);
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();
});

it("keeps the newest version when the server refresh lags behind an undo", async () => {
mocks.remove.mockResolvedValueOnce({ ok: true, value: removedFile });
mocks.restore.mockResolvedValue({ ok: true, value: { ...file, version: 3 } });
const { rerender } = render(
<AccountDocuments target={target} files={[file]} canWrite />,
);
fireEvent.click(screen.getByRole("button", { name: "Remove External MNDA" }));
fireEvent.change(screen.getByLabelText("Why remove it?"), {
target: { value: "Wrong record" },
});
fireEvent.click(screen.getByRole("button", { name: "Remove document" }));
fireEvent.click(await screen.findByRole("button", { name: "Undo" }));
await screen.findByText("“External MNDA” is back on this record.");
// A refresh that only saw the removal arrives after the undo.
rerender(
<AccountDocuments
target={target}
files={[]}
removed={[removedFile]}
canWrite
/>,
);
expect(
screen.getByRole("link", { name: "Download External MNDA" }),
).toBeInTheDocument();
mocks.remove.mockResolvedValueOnce({
ok: true,
value: { ...removedFile, version: 4 },
});
fireEvent.click(screen.getByRole("button", { name: "Remove External MNDA" }));
fireEvent.change(screen.getByLabelText("Why remove it?"), {
target: { value: "Wrong record again" },
});
fireEvent.click(screen.getByRole("button", { name: "Remove document" }));
await waitFor(() =>
expect(mocks.remove).toHaveBeenLastCalledWith(
expect.objectContaining({ expectedVersion: 3 }),
),
);
// The undo offered for one removal never outlives the next action.
expect(
screen.queryByText("“External MNDA” is back on this record."),
).toBeNull();
});
Loading
Loading