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
50 changes: 50 additions & 0 deletions Server/src/routes/donor.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
excluded,
deceased,
tags,
min_donation,

Check warning on line 117 in Server/src/routes/donor.js

View workflow job for this annotation

GitHub Actions / lint

'min_donation' is assigned a value but never used
search
} = req.query;

Expand Down Expand Up @@ -833,4 +833,54 @@
}
});

/**
* Get multiple donors by their IDs
* @name POST /api/donors/batch
* @function
* @memberof module:DonorAPI
* @inner
*/
router.post('/batch', protect, async (req, res) => {
try {
const { donorIds } = req.body;

if (!donorIds || !Array.isArray(donorIds)) {
return res.status(400).json({ message: 'Invalid donor IDs format' });
}

// Convert string IDs to numbers
const numericIds = donorIds.map(id => Number(id));

// Get donors with their event donor status
const donors = await prisma.donor.findMany({
where: {
id: {
in: numericIds
},
deceased: false
},
include: {
eventDonors: {
where: {
status: {
in: ['Pending', 'Approved']
}
}
}
}
});

// Filter donors that have at least one pending or approved event donor status
const validDonors = donors.filter(donor => donor.eventDonors.length > 0);

// Format response
res.json({
donors: formatDonor(validDonors)
});
} catch (error) {
console.error('Error fetching batch donors:', error);
res.status(500).json({ message: 'Internal server error', error: error.message });
}
});

export default router;
85 changes: 85 additions & 0 deletions Server/src/routes/event.js
Original file line number Diff line number Diff line change
Expand Up @@ -1924,4 +1924,89 @@ router.get('/:eventId/recommended-donors', protect, async (req, res) => {
}
});

/**
* Get all donor IDs for a specific event
*
* @name GET /api/events/:id/donor-ids
* @function
* @memberof module:EventAPI
* @inner
* @param {string} req.params.id - Event ID
* @param {string} req.headers.authorization - Bearer token for authentication
* @returns {Object} 200 - Array of donor IDs
* @returns {Error} 400 - Invalid event ID format
* @returns {Error} 401 - Unauthorized access
* @returns {Error} 404 - Event not found
* @returns {Error} 500 - Server error
*
* @example
* // Request
* GET /api/events/1/donor-ids
* Authorization: Bearer <token>
*
* // Success Response
* {
* "donorIds": [1, 2, 3, 4, 5]
* }
*/
router.get('/:id/donor-ids', protect, async (req, res) => {
try {
let eventId;
try {
eventId = parseInt(req.params.id);
if (isNaN(eventId)) {
return res.status(400).json({ message: 'Invalid event ID format' });
}
} catch (error) {
return res.status(400).json({ message: 'Invalid event ID format' });
}

// Verify if event exists
const event = await prisma.event.findUnique({
where: { id: eventId, isDeleted: false },
include: {
donorLists: {
select: {
id: true
}
}
}
});

if (!event) {
return res.status(404).json({ message: 'Event not found' });
}

// Return empty array if event has no donor list
if (!event.donorLists || event.donorLists.length === 0) {
return res.json({
donorIds: []
});
}

// Get the first donor list ID of the event
const donorListId = event.donorLists[0].id;

// Get all donor IDs
const eventDonors = await prisma.eventDonor.findMany({
where: {
donorListId: donorListId
},
select: {
donorId: true
}
});

// Extract donor IDs array
const donorIds = eventDonors.map(ed => ed.donorId);

res.json({
donorIds
});
} catch (error) {
console.error('Error fetching event donor IDs:', error);
res.status(500).json({ message: 'Internal server error', error: error.message });
}
});

export default router;
2 changes: 1 addition & 1 deletion client/src/components/donors/Donors.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { FaUser, FaPlus, FaAngleDown, FaSpinner, FaDownload } from 'react-icons/fa';
import { getEvents, getEventById, getEventDonors } from '../../services/eventService';
import { getAvailableDonors, removeDonorFromEvent, getEventDonorStats, updateEventDonor, exportEventDonorsToCsv } from '../../services/donorService';

Check warning on line 4 in client/src/components/donors/Donors.jsx

View workflow job for this annotation

GitHub Actions / lint

'getAvailableDonors' is defined but never used
import { useLocation } from 'react-router-dom';
import './Donors.css';
import DonorList from './DonorList';
Expand Down Expand Up @@ -57,7 +57,7 @@
// Fetch events on component mount
useEffect(() => {
fetchEvents();
}, []);

Check warning on line 60 in client/src/components/donors/Donors.jsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has a missing dependency: 'fetchEvents'. Either include it or remove the dependency array

// Handle event selection from location state
useEffect(() => {
Expand Down Expand Up @@ -90,7 +90,7 @@
fetchEventStats();
}
}
}, [selectedEvent, searchQuery, currentPage]);

Check warning on line 93 in client/src/components/donors/Donors.jsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'fetchEventDonors' and 'fetchEventStats'. Either include them or remove the dependency array

// Fetch events
const fetchEvents = async () => {
Expand Down Expand Up @@ -894,7 +894,7 @@
<div className="modal-body">
<div className="donor-profile-simple">
<h4>{selectedDonor.donor?.firstName || selectedDonor.firstName} {selectedDonor.donor?.lastName || selectedDonor.lastName}</h4>
{(selectedDonor.donor?.organizationName || selectedDonor.organizationName) && (
{((selectedDonor.donor?.organizationName || selectedDonor.organizationName)?.trim()) && (
Copy link

Copilot AI Apr 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider handling the case where the organization name consists only of whitespace. If the trimmed value is an empty string, you might want to provide a fallback or avoid rendering the element.

Suggested change
{((selectedDonor.donor?.organizationName || selectedDonor.organizationName)?.trim()) && (
{((selectedDonor.donor?.organizationName || selectedDonor.organizationName)?.trim().length > 0) && (

Copilot uses AI. Check for mistakes.
<p className="org-name">{selectedDonor.donor?.organizationName || selectedDonor.organizationName}</p>
)}
</div>
Expand Down
75 changes: 30 additions & 45 deletions client/src/services/donorService.js
Original file line number Diff line number Diff line change
Expand Up @@ -322,56 +322,23 @@ export const exportDonorsToCsv = async () => {
*/
export const exportEventDonorsToCsv = async (eventId) => {
try {
// Get event details
const donorsData = await fetchWithAuth(`/api/events/${eventId}/donors`);
let eventDonors = donorsData.donors || [];
// Get donor IDs for the event
const donorIdsData = await fetchWithAuth(`/api/events/${eventId}/donor-ids`);
const validDonorIds = donorIdsData.donorIds || [];

if (eventDonors.length === 0) {
throw new Error('No donors to export');
}

const donorPromises = eventDonors.map(async (eventDonor) => {
// Get donor ID from eventDonor
const donorId = eventDonor.donor?.id || eventDonor.donor_id || eventDonor.donorId;

if (!donorId) {
console.warn('Could not find donor ID:', eventDonor);
return null;
}

// Skip excluded donors
if (eventDonor.status === 'Excluded') {
return null;
}

try {
// Get complete donor data
const donorData = await fetchWithAuth(`/api/donors/${donorId}`);

// Skip deceased donors
if (donorData.deceased === true || donorData.is_deceased === true) {
console.log(`Skipping deceased donor ID=${donorId}`);
return null;
}

// Return only donor data, not event donor relationship data
return donorData;
} catch (error) {
console.error(`Error fetching data for donor ID=${donorId}:`, error.message);
return null;
}
});

// Wait for all donor data to be fetched and filter out nulls
let enrichedDonors = await Promise.all(donorPromises);
enrichedDonors = enrichedDonors.filter(donor => donor !== null);

if (enrichedDonors.length === 0) {
if (validDonorIds.length === 0) {
throw new Error('No valid donors to export');
}

// Get all donor data in one batch request
const donors = await getBatchDonors(validDonorIds);

if (donors.length === 0) {
throw new Error('No valid donors to export after filtering deceased and excluded donors');
}

// Use the generic jsonToCsv function
return jsonToCsv(enrichedDonors, {
return jsonToCsv(donors, {
excludeFields: ['eventDonors', 'tags', 'deceased', 'is_deceased']
});
} catch (error) {
Expand Down Expand Up @@ -587,4 +554,22 @@ export const getRecommendedDonors = async (eventId) => {
console.error('Error in getRecommendedDonors:', error);
throw error;
}
};

/**
* Get multiple donors by their IDs
* @param {Array} donorIds - Array of donor IDs
* @returns {Promise<Array>} Array of donor data
*/
export const getBatchDonors = async (donorIds) => {
try {
const result = await fetchWithAuth(`/api/donors/batch`, {
method: 'POST',
Copy link

Copilot AI Apr 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensure that fetchWithAuth includes the appropriate 'Content-Type': 'application/json' header for POST requests so that the JSON payload is processed correctly by the server.

Suggested change
method: 'POST',
method: 'POST',
headers: {
'Content-Type': 'application/json'
},

Copilot uses AI. Check for mistakes.
body: JSON.stringify({ donorIds })
});
return result.donors || [];
} catch (error) {
console.error('Error fetching batch donors:', error);
throw error;
}
};
Loading