Skip to content

Commit 870dcbc

Browse files
authored
Merge pull request #2322 from Giveth/codex/v5-givbacks-donation-export
Add v5 GivBacks donation CSV export
2 parents 424ffe7 + d1a28fc commit 870dcbc

5 files changed

Lines changed: 1220 additions & 0 deletions

File tree

src/server/adminJs/adminJs-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface AdminJsContextInterface {
1111
}
1212

1313
export interface AdminJsRequestInterface {
14+
method?: string;
1415
payload?: any;
1516
record?: any;
1617
rawHeaders?: any;
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
import React, { useEffect, useState } from 'react';
2+
import {
3+
Box,
4+
Button,
5+
Input,
6+
Label,
7+
Select,
8+
Text,
9+
} from '@adminjs/design-system';
10+
import { ApiClient } from 'adminjs';
11+
12+
const api = new ApiClient();
13+
14+
const downloadCsv = (csvContent: string, fileName: string) => {
15+
const blob = new Blob([csvContent], { type: 'text/csv' });
16+
const url = window.URL.createObjectURL(blob);
17+
const anchor = document.createElement('a');
18+
anchor.href = url;
19+
anchor.download = fileName;
20+
document.body.appendChild(anchor);
21+
anchor.click();
22+
window.URL.revokeObjectURL(url);
23+
document.body.removeChild(anchor);
24+
};
25+
26+
const FilterField = ({
27+
label,
28+
name,
29+
value,
30+
onChange,
31+
placeholder,
32+
}: {
33+
label: string;
34+
name: string;
35+
value: string;
36+
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
37+
placeholder: string;
38+
}) => (
39+
<Box mb="lg" width={['100%', '48%']}>
40+
<Label>{label}</Label>
41+
<Input
42+
name={name}
43+
value={value}
44+
onChange={onChange}
45+
placeholder={placeholder}
46+
/>
47+
</Box>
48+
);
49+
50+
const GivbacksEligibleDonationsExport = props => {
51+
const { resource } = props;
52+
const [filters, setFilters] = useState({
53+
fromDate: '',
54+
toDate: '',
55+
networkId: '',
56+
projectId: '',
57+
userId: '',
58+
isEligibleForGivbacks: '',
59+
});
60+
const [isExporting, setIsExporting] = useState(false);
61+
const [isLoadingThresholds, setIsLoadingThresholds] = useState(true);
62+
const [thresholds, setThresholds] = useState<{
63+
defaultMinimumUsdAmount: number;
64+
communityOfMakersMinimumUsdAmount: number;
65+
} | null>(null);
66+
67+
useEffect(() => {
68+
const loadThresholds = async () => {
69+
setIsLoadingThresholds(true);
70+
71+
try {
72+
const { data } = await api.resourceAction({
73+
resourceId: resource.id,
74+
actionName: 'exportGivbacksEligibleDonations',
75+
});
76+
77+
if (data && data.thresholds) {
78+
setThresholds(data.thresholds);
79+
}
80+
} catch {
81+
setThresholds(null);
82+
} finally {
83+
setIsLoadingThresholds(false);
84+
}
85+
};
86+
87+
loadThresholds();
88+
}, [resource.id]);
89+
90+
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
91+
const { name, value } = event.target;
92+
setFilters(current => ({ ...current, [name]: value }));
93+
};
94+
95+
const handleExport = async () => {
96+
setIsExporting(true);
97+
98+
try {
99+
const { data } = await api.resourceAction({
100+
resourceId: resource.id,
101+
actionName: 'exportGivbacksEligibleDonations',
102+
method: 'post',
103+
data: filters,
104+
});
105+
106+
if (data && data.csvContent) {
107+
downloadCsv(
108+
data.csvContent,
109+
data.fileName || 'givbacks-eligible-donations.csv',
110+
);
111+
}
112+
113+
if (data && data.notice && data.notice.message) {
114+
alert(data.notice.message);
115+
}
116+
} catch (error) {
117+
alert(
118+
`Export failed: ${
119+
error instanceof Error ? error.message : 'Unknown error'
120+
}`,
121+
);
122+
} finally {
123+
setIsExporting(false);
124+
}
125+
};
126+
127+
return (
128+
<Box variant="grey">
129+
<Box variant="white" p="xl">
130+
<Text fontSize="xl" fontWeight="bold" mb="default">
131+
Export GivBack Eligible Donations
132+
</Text>
133+
<Text mb="xl" color="grey60">
134+
Download a CSV of verified donations evaluated against the current
135+
GivBack eligibility rules. All filters are optional.
136+
</Text>
137+
<Box display="flex" flexWrap="wrap" justifyContent="space-between">
138+
<FilterField
139+
label="From date"
140+
name="fromDate"
141+
value={filters.fromDate}
142+
onChange={handleChange}
143+
placeholder="2026-01-01"
144+
/>
145+
<FilterField
146+
label="To date"
147+
name="toDate"
148+
value={filters.toDate}
149+
onChange={handleChange}
150+
placeholder="2026-12-31"
151+
/>
152+
<FilterField
153+
label="Network ID"
154+
name="networkId"
155+
value={filters.networkId}
156+
onChange={handleChange}
157+
placeholder="10"
158+
/>
159+
<FilterField
160+
label="Project ID"
161+
name="projectId"
162+
value={filters.projectId}
163+
onChange={handleChange}
164+
placeholder="123"
165+
/>
166+
<FilterField
167+
label="User ID"
168+
name="userId"
169+
value={filters.userId}
170+
onChange={handleChange}
171+
placeholder="456"
172+
/>
173+
<Box mb="lg" width={['100%', '48%']}>
174+
<Label>Eligibility</Label>
175+
<Select
176+
value={
177+
[
178+
{ value: '', label: 'All donations' },
179+
{ value: 'true', label: 'Eligible only' },
180+
{ value: 'false', label: 'Ineligible only' },
181+
].find(
182+
option => option.value === filters.isEligibleForGivbacks,
183+
) || null
184+
}
185+
onChange={selected =>
186+
setFilters(current => ({
187+
...current,
188+
isEligibleForGivbacks: selected?.value || '',
189+
}))
190+
}
191+
options={[
192+
{ value: '', label: 'All donations' },
193+
{ value: 'true', label: 'Eligible only' },
194+
{ value: 'false', label: 'Ineligible only' },
195+
]}
196+
isClearable
197+
/>
198+
</Box>
199+
</Box>
200+
<Text mb="xl" color="grey60" fontSize="sm">
201+
{isLoadingThresholds
202+
? 'Loading current thresholds...'
203+
: thresholds
204+
? `Current thresholds: ${thresholds.defaultMinimumUsdAmount} USD by default, and ${thresholds.communityOfMakersMinimumUsdAmount} USD for giveth-community-of-makers.`
205+
: 'Thresholds use the default GivBack export values.'}
206+
</Text>
207+
<Button
208+
onClick={handleExport}
209+
disabled={isExporting || isLoadingThresholds}
210+
variant="primary"
211+
size="lg"
212+
isLoading={isExporting}
213+
>
214+
{isLoadingThresholds
215+
? 'Loading thresholds...'
216+
: isExporting
217+
? 'Exporting...'
218+
: 'Download CSV'}
219+
</Button>
220+
</Box>
221+
</Box>
222+
);
223+
};
224+
225+
export default GivbacksEligibleDonationsExport;

src/server/adminJs/tabs/donationTab.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ActionContext } from 'adminjs';
2+
import adminJs from 'adminjs';
23
import moment from 'moment';
34
import { ILike, SelectQueryBuilder } from 'typeorm';
45
import { CoingeckoPriceAdapter } from '../../../adapters/price/CoingeckoPriceAdapter';
@@ -29,6 +30,10 @@ import {
2930
addDonationsSheetToSpreadsheet,
3031
initExportSpreadsheet,
3132
} from '../../../services/googleSheets';
33+
import {
34+
exportEligibleDonations,
35+
getCurrentEligibilityThresholds,
36+
} from '../../../services/givbacksEligibleDonationsCsvService';
3237
import { updateProjectStatistics } from '../../../services/projectService';
3338
import {
3439
updateUserTotalDonated,
@@ -794,6 +799,90 @@ export const exportDonationsWithFiltersToCsv = async (
794799
}
795800
};
796801

802+
const parseOptionalPositiveInteger = (
803+
value: unknown,
804+
fieldName: string,
805+
): number | undefined => {
806+
if (value === null || value === undefined) {
807+
return undefined;
808+
}
809+
810+
const normalizedValue = String(value).trim();
811+
if (normalizedValue.length === 0) {
812+
return undefined;
813+
}
814+
815+
const parsedValue = Number(normalizedValue);
816+
if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
817+
throw new Error(
818+
`Invalid ${fieldName} value "${normalizedValue}". Expected a positive integer.`,
819+
);
820+
}
821+
822+
return parsedValue;
823+
};
824+
825+
const parseOptionalBoolean = (value: unknown): boolean | undefined => {
826+
if (typeof value !== 'string' || value.trim().length === 0) {
827+
return undefined;
828+
}
829+
830+
if (value === 'true') return true;
831+
if (value === 'false') return false;
832+
833+
throw new Error('Invalid isEligibleForGivbacks value');
834+
};
835+
836+
const readOptionalString = (value: unknown): string | undefined =>
837+
typeof value === 'string' && value.trim().length > 0 ? value : undefined;
838+
839+
const getErrorMessage = (error: unknown): string =>
840+
(error instanceof Error ? error.message : String(error)) || 'Unknown error';
841+
842+
export const exportGivbacksEligibleDonations = async (
843+
request: AdminJsRequestInterface,
844+
) => {
845+
try {
846+
if (request.method !== 'post') {
847+
return {
848+
thresholds: await getCurrentEligibilityThresholds(),
849+
};
850+
}
851+
852+
const payload = request.payload || {};
853+
const { csvContent, fileName, totalDonations } =
854+
await exportEligibleDonations({
855+
fromDate: readOptionalString(payload.fromDate),
856+
toDate: readOptionalString(payload.toDate),
857+
networkId: parseOptionalPositiveInteger(payload.networkId, 'networkId'),
858+
projectId: parseOptionalPositiveInteger(payload.projectId, 'projectId'),
859+
userId: parseOptionalPositiveInteger(payload.userId, 'userId'),
860+
isEligibleForGivbacks: parseOptionalBoolean(
861+
payload.isEligibleForGivbacks,
862+
),
863+
});
864+
865+
return {
866+
csvContent,
867+
fileName,
868+
totalDonations,
869+
notice: {
870+
message: `Exported ${totalDonations} GivBack-eligible donations`,
871+
type: 'success',
872+
},
873+
};
874+
} catch (e) {
875+
const message = getErrorMessage(e);
876+
logger.error('exportGivbacksEligibleDonations() error', e);
877+
return {
878+
notice: {
879+
message,
880+
type: 'danger',
881+
},
882+
};
883+
}
884+
};
885+
797886
// Spreadsheet filters included
798887
const sendDonationsToGoogleSheet = async (
799888
donations: Donation[],
@@ -1386,6 +1475,19 @@ export const donationTab = {
13861475
handler: exportDonationsWithFiltersToCsv,
13871476
component: false,
13881477
},
1478+
exportGivbacksEligibleDonations: {
1479+
actionType: 'resource',
1480+
isVisible: true,
1481+
isAccessible: ({ currentAdmin }) =>
1482+
canAccessDonationAction(
1483+
{ currentAdmin },
1484+
ResourceActions.EXPORT_FILTER_TO_CSV,
1485+
),
1486+
handler: exportGivbacksEligibleDonations,
1487+
component: adminJs.bundle(
1488+
'./components/GivbacksEligibleDonationsExport',
1489+
),
1490+
},
13891491
importIdrissDonations: {
13901492
actionType: 'resource',
13911493
isVisible: true,

0 commit comments

Comments
 (0)