Skip to content

Commit f130e2e

Browse files
committed
Extended audit log to enable batch undo
1 parent 07b1683 commit f130e2e

4 files changed

Lines changed: 82 additions & 11 deletions

File tree

src/Home.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export const Home = () => {
3939

4040
const [status, setStatus] = useState<Record<string, string>>({});
4141
const [undoLog, setUndoLog] = useState<UndoEntry[]>([]);
42+
const [batchId, setBatchId] = useState<string | undefined>(undefined);
4243
const [activeTab, setActiveTab] = useState<string>("step1");
4344

4445
const handleUndo = async (onProgress?: (done: number, total: number) => void) => {
@@ -60,6 +61,7 @@ export const Home = () => {
6061
setIsExporting(false);
6162
setStatus({});
6263
setUndoLog([]);
64+
setBatchId(undefined);
6365
setShowFinalCount(false);
6466
setExportCategories({ ...defaultCategories });
6567
};
@@ -129,10 +131,11 @@ export const Home = () => {
129131
exportCategories={exportCategories}
130132
setExportCategories={setExportCategories}
131133
setUndoLog={setUndoLog}
134+
setBatchId={setBatchId}
132135
/>
133136
)}
134137
{activeTab === "step4" && (
135-
<TabRun dataExportSource={dataExportSource} isExporting={isExporting} status={status} undoCount={undoLog.length} onUndo={handleUndo} />
138+
<TabRun dataExportSource={dataExportSource} isExporting={isExporting} status={status} undoCount={undoLog.length} onUndo={handleUndo} batchId={batchId} />
136139
)}
137140
</ErrorBoundary>
138141
</CardContent>

src/components/TabDestination.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ interface Props {
3636
exportCategories: ExportCategoriesInterface;
3737
setExportCategories: (cats: ExportCategoriesInterface) => void;
3838
setUndoLog: (log: UndoEntry[]) => void;
39+
setBatchId: (id?: string) => void;
3940
}
4041

4142
export const TabDestination = (props: Props) => {
@@ -111,8 +112,9 @@ export const TabDestination = (props: Props) => {
111112
try {
112113
switch (e) {
113114
case DataSourceType.B1_DB: {
114-
const log = await exportToB1Db(exportData, setProgress);
115-
props.setUndoLog(log);
115+
const result = await exportToB1Db(exportData, setProgress, props.dataImportSource);
116+
props.setUndoLog(result.undoLog);
117+
props.setBatchId(result.batchId);
116118
break;
117119
}
118120
case DataSourceType.B1_ZIP: {

src/components/TabRun.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface Props {
99
status: Record<string, string>;
1010
undoCount?: number;
1111
onUndo?: (onProgress?: (done: number, total: number) => void) => Promise<void>;
12+
batchId?: string;
1213
}
1314

1415
export const TabRun = (props: Props) => {
@@ -183,6 +184,12 @@ export const TabRun = (props: Props) => {
183184
</Typography>
184185
)}
185186

187+
{props.dataExportSource === DataSourceType.B1_DB && props.batchId && (
188+
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
189+
This import was saved as a batch and can be undone anytime from B1Admin → Settings → Batches.
190+
</Typography>
191+
)}
192+
186193
{props.dataExportSource === DataSourceType.B1_DB && !!props.undoCount && undoState === "idle" && (
187194
<Box sx={{ mt: 3 }}>
188195
<Button variant="outlined" color="error" startIcon={<Undo />} onClick={() => setConfirmUndo(true)}>

src/helpers/ExportHelpers/ExportB1DbHelper.ts

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,36 @@ const MAX_RETRIES = 3;
1414

1515
export interface UndoEntry { endpoint: string; apiName: any; id: string }
1616

17+
// Endpoints the Api's audit registry marks batch-capable. Only these accept X-Batch-Id;
18+
// any other route 400s when the header is present, so the batch header is scoped to just these.
19+
const BATCH_CAPABLE = [
20+
"/campuses",
21+
"/people",
22+
"/groups",
23+
"/groupmembers",
24+
"/forms",
25+
"/donations",
26+
"/households",
27+
"/questions",
28+
"/services",
29+
"/servicetimes",
30+
"/groupservicetimes",
31+
"/funds",
32+
"/donationbatches",
33+
"/funddonations"
34+
];
35+
1736
// ponytail: module-level log of every row this run inserted; reversed = FK-safe delete order
1837
let undoLog: UndoEntry[] = [];
1938

39+
// Set only while an import is running; the request hook stamps the header on batch-capable calls.
40+
let activeBatchId: string | undefined;
41+
const batchRequestHook = (url: string, requestOptions: any) => {
42+
if (activeBatchId && BATCH_CAPABLE.some(e => url.endsWith(e))) {
43+
requestOptions.headers = { ...(requestOptions.headers || {}), "X-Batch-Id": activeBatchId };
44+
}
45+
};
46+
2047
const postInBatches = async <T extends { id?: string }>(
2148
endpoint: string,
2249
data: T[],
@@ -57,7 +84,9 @@ const postInBatches = async <T extends { id?: string }>(
5784
return results;
5885
};
5986

60-
const exportToB1Db = async (exportData: ImportDataInterface, updateProgress: (name: string, status: string) => void): Promise<UndoEntry[]> => {
87+
export interface ImportResult { undoLog: UndoEntry[]; batchId?: string }
88+
89+
const exportToB1Db = async (exportData: ImportDataInterface, updateProgress: (name: string, status: string) => void, importSourceName?: string): Promise<ImportResult> => {
6190

6291
undoLog = [];
6392
const sleep = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds));
@@ -78,13 +107,43 @@ const exportToB1Db = async (exportData: ImportDataInterface, updateProgress: (na
78107
}
79108
};
80109

81-
const campusResult = await exportCampuses(exportData, runImport);
82-
const tmpPeople = await exportPeople(exportData, runImport, updateProgress);
83-
const tmpGroups = await exportGroups(exportData, tmpPeople, campusResult.serviceTimes, runImport, updateProgress);
84-
await exportAttendance(exportData, tmpPeople, tmpGroups, campusResult.services, campusResult.serviceTimes, runImport, updateProgress);
85-
await exportDonations(exportData, tmpPeople, runImport, updateProgress);
86-
await exportForms(exportData, tmpPeople, tmpGroups, runImport);
87-
return undoLog;
110+
let batchId: string | undefined;
111+
try {
112+
const label = `Import from ${importSourceName || "file"} ${new Date().toLocaleDateString()}`;
113+
const batch = await ApiHelper.post("/batches", { label, source: "import" }, "MembershipApi");
114+
batchId = batch?.id;
115+
} catch (e) {
116+
// Batch tracking is best-effort; a failure here must not block the import itself.
117+
console.error("Could not create undo batch; import will not be undoable from B1Admin", e);
118+
}
119+
120+
const prevOnRequest = ApiHelper.onRequest;
121+
if (batchId) {
122+
activeBatchId = batchId;
123+
ApiHelper.onRequest = batchRequestHook;
124+
}
125+
126+
try {
127+
const campusResult = await exportCampuses(exportData, runImport);
128+
const tmpPeople = await exportPeople(exportData, runImport, updateProgress);
129+
const tmpGroups = await exportGroups(exportData, tmpPeople, campusResult.serviceTimes, runImport, updateProgress);
130+
await exportAttendance(exportData, tmpPeople, tmpGroups, campusResult.services, campusResult.serviceTimes, runImport, updateProgress);
131+
await exportDonations(exportData, tmpPeople, runImport, updateProgress);
132+
await exportForms(exportData, tmpPeople, tmpGroups, runImport);
133+
} finally {
134+
activeBatchId = undefined;
135+
ApiHelper.onRequest = prevOnRequest;
136+
if (batchId) {
137+
try {
138+
await ApiHelper.post(`/batches/${batchId}/complete`, {}, "MembershipApi");
139+
console.log(`Import recorded as batch ${batchId}. It can be undone from B1Admin Settings → Batches.`);
140+
} catch (e) {
141+
console.error("Could not close undo batch", batchId, e);
142+
}
143+
}
144+
}
145+
146+
return { undoLog, batchId };
88147
};
89148

90149
export const undoB1DbImport = async (log: UndoEntry[], onProgress?: (done: number, total: number) => void) => {

0 commit comments

Comments
 (0)