Add CEO-safe company portability flows

Expose CEO-scoped import/export preview and apply routes, keep safe imports non-destructive, add export preview-first UI behavior, and document the new portability workflows.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-18 21:54:10 -05:00
parent 685c7549e1
commit 51ca713181
18 changed files with 1166 additions and 96 deletions

View File

@@ -1,5 +1,6 @@
import type {
Company,
CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult,
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
@@ -38,12 +39,41 @@ export const companiesApi = {
companyId: string,
data: {
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
agents?: string[];
skills?: string[];
projects?: string[];
issues?: string[];
projectIssues?: string[];
selectedFiles?: string[];
},
) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/export`, data),
exportPreview: (
companyId: string,
data: {
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
agents?: string[];
skills?: string[];
projects?: string[];
issues?: string[];
projectIssues?: string[];
selectedFiles?: string[];
},
) =>
api.post<CompanyPortabilityExportPreviewResult>(`/companies/${companyId}/exports/preview`, data),
exportPackage: (
companyId: string,
data: {
include?: { company?: boolean; agents?: boolean; projects?: boolean; issues?: boolean };
agents?: string[];
skills?: string[];
projects?: string[];
issues?: string[];
projectIssues?: string[];
selectedFiles?: string[];
},
) =>
api.post<CompanyPortabilityExportResult>(`/companies/${companyId}/exports`, data),
importPreview: (data: CompanyPortabilityPreviewRequest) =>
api.post<CompanyPortabilityPreviewResult>("/companies/import/preview", data),
importBundle: (data: CompanyPortabilityImportRequest) =>

View File

@@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import type { CompanyPortabilityExportResult, CompanyPortabilityManifest } from "@paperclipai/shared";
import type {
CompanyPortabilityExportPreviewResult,
CompanyPortabilityExportResult,
CompanyPortabilityManifest,
} from "@paperclipai/shared";
import { useNavigate, useLocation } from "@/lib/router";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -526,12 +530,13 @@ export function CompanyExport() {
const navigate = useNavigate();
const location = useLocation();
const [exportData, setExportData] = useState<CompanyPortabilityExportResult | null>(null);
const [exportData, setExportData] = useState<CompanyPortabilityExportPreviewResult | null>(null);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [checkedFiles, setCheckedFiles] = useState<Set<string>>(new Set());
const [treeSearch, setTreeSearch] = useState("");
const [taskLimit, setTaskLimit] = useState(TASKS_PAGE_SIZE);
const [includeTasks, setIncludeTasks] = useState(false);
const savedExpandedRef = useRef<Set<string> | null>(null);
const initialFileFromUrl = useRef(filePathFromLocation(location.pathname));
@@ -573,20 +578,21 @@ export function CompanyExport() {
]);
}, [setBreadcrumbs]);
// Load export data on mount
const exportMutation = useMutation({
const exportPreviewMutation = useMutation({
mutationFn: () =>
companiesApi.exportBundle(selectedCompanyId!, {
include: { company: true, agents: true, projects: true, issues: true },
companiesApi.exportPreview(selectedCompanyId!, {
include: { company: true, agents: true, projects: true, issues: includeTasks },
}),
onSuccess: (result) => {
setExportData(result);
// Check all files EXCEPT tasks by default
const checked = new Set<string>();
for (const filePath of Object.keys(result.files)) {
if (!isTaskPath(filePath)) checked.add(filePath);
}
setCheckedFiles(checked);
setCheckedFiles((prev) => {
const next = new Set<string>();
for (const filePath of Object.keys(result.files)) {
if (prev.has(filePath)) next.add(filePath);
else if (!isTaskPath(filePath)) next.add(filePath);
}
return next;
});
// Expand top-level dirs (except tasks — collapsed by default)
const tree = buildFileTree(result.files);
const topDirs = new Set<string>();
@@ -618,13 +624,36 @@ export function CompanyExport() {
},
});
const downloadMutation = useMutation({
mutationFn: () =>
companiesApi.exportPackage(selectedCompanyId!, {
include: { company: true, agents: true, projects: true, issues: includeTasks },
selectedFiles: Array.from(checkedFiles).sort(),
}),
onSuccess: (result) => {
const resultCheckedFiles = new Set(Object.keys(result.files));
downloadZip(result, resultCheckedFiles, result.files);
pushToast({
tone: "success",
title: "Export downloaded",
body: `${resultCheckedFiles.size} file${resultCheckedFiles.size === 1 ? "" : "s"} exported as ${result.rootPath}.zip`,
});
},
onError: (err) => {
pushToast({
tone: "error",
title: "Export failed",
body: err instanceof Error ? err.message : "Failed to build export package.",
});
},
});
useEffect(() => {
if (selectedCompanyId && !exportData && !exportMutation.isPending) {
exportMutation.mutate();
}
// Only run on mount
if (!selectedCompanyId || exportPreviewMutation.isPending) return;
setExportData(null);
exportPreviewMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedCompanyId]);
}, [selectedCompanyId, includeTasks]);
const tree = useMemo(
() => (exportData ? buildFileTree(exportData.files) : []),
@@ -774,20 +803,15 @@ export function CompanyExport() {
}
function handleDownload() {
if (!exportData) return;
downloadZip(exportData, checkedFiles, effectiveFiles);
pushToast({
tone: "success",
title: "Export downloaded",
body: `${selectedCount} file${selectedCount === 1 ? "" : "s"} exported as ${exportData.rootPath}.zip`,
});
if (!exportData || checkedFiles.size === 0 || downloadMutation.isPending) return;
downloadMutation.mutate();
}
if (!selectedCompanyId) {
return <EmptyState icon={Package} message="Select a company to export." />;
}
if (exportMutation.isPending && !exportData) {
if (exportPreviewMutation.isPending && !exportData) {
return <PageSkeleton variant="detail" />;
}
@@ -809,6 +833,13 @@ export function CompanyExport() {
<span className="text-muted-foreground">
{selectedCount} / {totalFiles} file{totalFiles === 1 ? "" : "s"} selected
</span>
<button
type="button"
className="text-muted-foreground underline underline-offset-4"
onClick={() => setIncludeTasks((value) => !value)}
>
{includeTasks ? "Hide task files" : "Load task files"}
</button>
{warnings.length > 0 && (
<span className="text-amber-500">
{warnings.length} warning{warnings.length === 1 ? "" : "s"}
@@ -818,10 +849,12 @@ export function CompanyExport() {
<Button
size="sm"
onClick={handleDownload}
disabled={selectedCount === 0}
disabled={selectedCount === 0 || downloadMutation.isPending}
>
<Download className="mr-1.5 h-3.5 w-3.5" />
Export {selectedCount} file{selectedCount === 1 ? "" : "s"}
{downloadMutation.isPending
? "Building export..."
: `Export ${selectedCount} file${selectedCount === 1 ? "" : "s"}`}
</Button>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
CompanyPortabilityCollisionStrategy,
CompanyPortabilityPreviewResult,
CompanyPortabilitySource,
CompanyPortabilityAdapterOverride,
@@ -609,6 +610,7 @@ export function CompanyImport() {
const [nameOverrides, setNameOverrides] = useState<Record<string, string>>({});
const [skippedSlugs, setSkippedSlugs] = useState<Set<string>>(new Set());
const [confirmedSlugs, setConfirmedSlugs] = useState<Set<string>>(new Set());
const [collisionStrategy, setCollisionStrategy] = useState<CompanyPortabilityCollisionStrategy>("rename");
// Adapter override state
const [adapterOverrides, setAdapterOverrides] = useState<Record<string, string>>({});
@@ -656,7 +658,7 @@ export function CompanyImport() {
targetMode === "new"
? { mode: "new_company", newCompanyName: newCompanyName || null }
: { mode: "existing_company", companyId: selectedCompanyId! },
collisionStrategy: "rename",
collisionStrategy,
});
},
onSuccess: (result) => {
@@ -760,7 +762,7 @@ export function CompanyImport() {
targetMode === "new"
? { mode: "new_company", newCompanyName: newCompanyName || null }
: { mode: "existing_company", companyId: selectedCompanyId! },
collisionStrategy: "rename",
collisionStrategy,
nameOverrides: buildFinalNameOverrides(),
selectedFiles: buildSelectedFiles(),
adapterOverrides: buildFinalAdapterOverrides(),
@@ -1116,6 +1118,24 @@ export function CompanyImport() {
</Field>
)}
<Field
label="Collision strategy"
hint="Board imports can rename, skip, or replace matching company content."
>
<select
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value={collisionStrategy}
onChange={(e) => {
setCollisionStrategy(e.target.value as CompanyPortabilityCollisionStrategy);
setImportPreview(null);
}}
>
<option value="rename">Rename on conflict</option>
<option value="skip">Skip on conflict</option>
<option value="replace">Replace existing</option>
</select>
</Field>
<div className="flex items-center gap-2">
<Button
size="sm"
@@ -1142,7 +1162,7 @@ export function CompanyImport() {
</span>
{conflicts.length > 0 && (
<span className="text-amber-500">
{conflicts.length} rename{conflicts.length === 1 ? "" : "s"}
{conflicts.length} conflict{conflicts.length === 1 ? "" : "s"}
</span>
)}
{importPreview.errors.length > 0 && (