Add total litter collected display to user profile

- Updated backend userService to calculate total litter collected across all attended events
- Added totalLitterCollected field to UserProfile interface
- Added new statistics card showing total litter collected in kg
- Updated profile grid layout to accommodate 5 statistics cards
- Displays litter collected with 1 decimal place precision
This commit is contained in:
2025-11-18 19:03:43 +00:00
parent 3d7b84f376
commit 3a6db92699
22 changed files with 1462 additions and 128 deletions

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE `event_registrations`
ADD COLUMN `litter_collected` DECIMAL(10, 2) NULL,
ADD COLUMN `contribution_note` TEXT NULL;

View File

@@ -96,13 +96,15 @@ model Event {
}
model EventRegistration {
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
eventId String @map("event_id") @db.VarChar(36)
registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0)
attended Boolean? @default(false)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_1")
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_2")
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
userId String @map("user_id") @db.VarChar(36)
eventId String @map("event_id") @db.VarChar(36)
registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0)
attended Boolean? @default(false)
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
contributionNote String? @map("contribution_note") @db.Text
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_1")
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_2")
@@unique([userId, eventId], map: "unique_user_event")
@@index([eventId], map: "event_id")

View File

@@ -405,4 +405,133 @@ export const eventController = {
});
}
},
// Get event attendees
async getEventAttendees(req: AuthRequest, res: Response) {
try {
const { id } = req.params;
const attendees = await eventService.getEventAttendees(id);
res.json(attendees);
} catch (error: any) {
console.error('Error fetching event attendees:', error);
res.status(500).json({
code: 'FETCH_ATTENDEES_ERROR',
message: 'Failed to fetch event attendees',
details: error.message,
timestamp: new Date().toISOString(),
});
}
},
// Update attendee status
async updateAttendeeStatus(req: AuthRequest, res: Response) {
try {
const clerkId = req.userId;
if (!clerkId) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'User not authenticated',
timestamp: new Date().toISOString(),
});
}
const userId = await findOrCreateUser(clerkId);
const { id, attendeeId } = req.params;
const event = await eventService.getEventById(id);
if (!event) {
return res.status(404).json({
code: 'EVENT_NOT_FOUND',
message: 'Event not found',
timestamp: new Date().toISOString(),
});
}
// Check if user is the organizer
if (event.organizerId !== userId) {
return res.status(403).json({
code: 'FORBIDDEN',
message: 'Only the event organizer can update attendee status',
timestamp: new Date().toISOString(),
});
}
const { attended, litterCollected, contributionNote } = req.body;
const updatedAttendee = await eventService.updateAttendeeStatus(id, attendeeId, {
attended,
litterCollected: litterCollected !== undefined ? parseFloat(litterCollected) : undefined,
contributionNote,
});
res.json(updatedAttendee);
} catch (error: any) {
console.error('Error updating attendee status:', error);
res.status(500).json({
code: 'UPDATE_ATTENDEE_ERROR',
message: 'Failed to update attendee status',
details: error.message,
timestamp: new Date().toISOString(),
});
}
},
// Bulk update attendees
async bulkUpdateAttendees(req: AuthRequest, res: Response) {
try {
const clerkId = req.userId;
if (!clerkId) {
return res.status(401).json({
code: 'UNAUTHORIZED',
message: 'User not authenticated',
timestamp: new Date().toISOString(),
});
}
const userId = await findOrCreateUser(clerkId);
const { id } = req.params;
const event = await eventService.getEventById(id);
if (!event) {
return res.status(404).json({
code: 'EVENT_NOT_FOUND',
message: 'Event not found',
timestamp: new Date().toISOString(),
});
}
// Check if user is the organizer
if (event.organizerId !== userId) {
return res.status(403).json({
code: 'FORBIDDEN',
message: 'Only the event organizer can update attendees',
timestamp: new Date().toISOString(),
});
}
const { attendees } = req.body;
if (!Array.isArray(attendees)) {
return res.status(400).json({
code: 'VALIDATION_ERROR',
message: 'attendees must be an array',
timestamp: new Date().toISOString(),
});
}
await eventService.bulkUpdateAttendees(id, attendees);
// Fetch updated attendees
const updatedAttendees = await eventService.getEventAttendees(id);
res.json(updatedAttendees);
} catch (error: any) {
console.error('Error bulk updating attendees:', error);
res.status(500).json({
code: 'BULK_UPDATE_ATTENDEES_ERROR',
message: 'Failed to bulk update attendees',
details: error.message,
timestamp: new Date().toISOString(),
});
}
},
};

View File

@@ -8,6 +8,7 @@ const router = Router();
router.get('/', eventController.getEvents);
router.get('/:id', eventController.getEvent);
router.get('/:id/registrations', eventController.getEventRegistrations);
router.get('/:id/attendees', eventController.getEventAttendees);
// Protected routes (require authentication)
router.post('/', requireAuth, eventController.createEvent);
@@ -17,5 +18,7 @@ router.post('/:id/register', requireAuth, eventController.registerForEvent);
router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent);
router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus);
router.post('/:id/complete', requireAuth, eventController.completeEvent);
router.patch('/:id/attendees/:attendeeId', requireAuth, eventController.updateAttendeeStatus);
router.post('/:id/attendees/bulk', requireAuth, eventController.bulkUpdateAttendees);
export default router;

View File

@@ -0,0 +1,57 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function applyMigration() {
try {
console.log('Applying migration: add_attendee_contributions');
// Check if columns exist
const result = await prisma.$queryRaw`
SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'beach_litter_mapping'
AND TABLE_NAME = 'event_registrations'
AND COLUMN_NAME IN ('litter_collected', 'contribution_note')
` as any[];
console.log('Existing columns:', result);
if (result.length === 0) {
console.log('Adding columns...');
await prisma.$executeRaw`
ALTER TABLE event_registrations
ADD COLUMN litter_collected DECIMAL(10, 2) NULL,
ADD COLUMN contribution_note TEXT NULL
`;
console.log('✓ Columns added successfully');
} else if (result.length === 1) {
const existingColumn = result[0].COLUMN_NAME;
const missingColumn = existingColumn === 'litter_collected' ? 'contribution_note' : 'litter_collected';
console.log(`Adding missing column: ${missingColumn}`);
await prisma.$executeRaw`
ALTER TABLE event_registrations
ADD COLUMN ${missingColumn} ${missingColumn === 'litter_collected' ? 'DECIMAL(10, 2)' : 'TEXT'} NULL
`;
console.log('✓ Column added successfully');
} else {
console.log('✓ Columns already exist');
}
} catch (error) {
console.error('Error applying migration:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
applyMigration()
.then(() => {
console.log('Migration completed');
process.exit(0);
})
.catch((error) => {
console.error('Migration failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,89 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function testAttendees() {
try {
console.log('Testing attendees query...\n');
// Get all events
const events = await prisma.event.findMany({
take: 1,
});
if (events.length === 0) {
console.log('No events found in database');
return;
}
const eventId = events[0].id;
console.log(`Testing with event ID: ${eventId}`);
console.log(`Event title: ${events[0].title}\n`);
// Get registrations
const registrations = await prisma.eventRegistration.findMany({
where: { eventId },
include: {
user: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
orderBy: {
registeredAt: 'asc',
},
});
console.log(`Found ${registrations.length} registrations\n`);
if (registrations.length > 0) {
console.log('Sample registration:');
const reg = registrations[0];
console.log({
id: reg.id,
userId: reg.userId,
eventId: reg.eventId,
registeredAt: reg.registeredAt,
attended: reg.attended,
litterCollected: reg.litterCollected,
contributionNote: reg.contributionNote,
user: reg.user,
});
}
// Test the mapping
const mapped = registrations.map((reg) => ({
id: reg.id,
userId: reg.userId,
eventId: reg.eventId,
registeredAt: reg.registeredAt || new Date(),
attended: reg.attended || false,
litterCollected: reg.litterCollected ? parseFloat(reg.litterCollected.toString()) : undefined,
contributionNote: reg.contributionNote || undefined,
user: reg.user,
}));
console.log('\nMapped data:');
console.log(JSON.stringify(mapped, null, 2));
console.log('\n✓ Test completed successfully');
} catch (error) {
console.error('Error testing attendees:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
testAttendees()
.then(() => process.exit(0))
.catch((error) => {
console.error('Test failed:', error);
process.exit(1);
});

View File

@@ -0,0 +1,43 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function verifyMigration() {
try {
console.log('Verifying migration...');
const result = await prisma.$queryRaw`
SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'beach_litter_mapping'
AND TABLE_NAME = 'event_registrations'
ORDER BY ORDINAL_POSITION
` as any[];
console.log('\nColumns in event_registrations table:');
console.table(result);
const hasLitterCollected = result.some((col: any) => col.COLUMN_NAME === 'litter_collected');
const hasContributionNote = result.some((col: any) => col.COLUMN_NAME === 'contribution_note');
if (hasLitterCollected && hasContributionNote) {
console.log('\n✓ Migration verified successfully!');
console.log('✓ litter_collected column exists');
console.log('✓ contribution_note column exists');
} else {
console.log('\n✗ Migration incomplete:');
if (!hasLitterCollected) console.log('✗ litter_collected column missing');
if (!hasContributionNote) console.log('✗ contribution_note column missing');
}
} catch (error) {
console.error('Error verifying migration:', error);
throw error;
} finally {
await prisma.$disconnect();
}
}
verifyMigration()
.then(() => process.exit(0))
.catch(() => process.exit(1));

View File

@@ -372,4 +372,111 @@ export const eventService = {
updatedAt: event.updatedAt,
};
},
async getEventAttendees(eventId: string) {
const registrations = await prisma.eventRegistration.findMany({
where: { eventId },
include: {
user: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
orderBy: {
registeredAt: 'asc',
},
});
return registrations.map((reg) => ({
id: reg.id,
userId: reg.userId,
eventId: reg.eventId,
registeredAt: reg.registeredAt || new Date(),
attended: reg.attended || false,
litterCollected: reg.litterCollected ? parseFloat(reg.litterCollected.toString()) : undefined,
contributionNote: reg.contributionNote || undefined,
user: reg.user,
}));
},
async updateAttendeeStatus(
eventId: string,
userId: string,
data: {
attended?: boolean;
litterCollected?: number;
contributionNote?: string;
}
) {
const registration = await prisma.eventRegistration.update({
where: {
userId_eventId: {
userId,
eventId,
},
},
data: {
attended: data.attended,
litterCollected: data.litterCollected,
contributionNote: data.contributionNote,
},
include: {
user: {
select: {
id: true,
username: true,
firstName: true,
lastName: true,
avatarUrl: true,
},
},
},
});
return {
id: registration.id,
userId: registration.userId,
eventId: registration.eventId,
registeredAt: registration.registeredAt || new Date(),
attended: registration.attended || false,
litterCollected: registration.litterCollected ? parseFloat(registration.litterCollected.toString()) : undefined,
contributionNote: registration.contributionNote || undefined,
user: registration.user,
};
},
async bulkUpdateAttendees(
eventId: string,
attendees: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}>
) {
const updates = await Promise.all(
attendees.map((attendee) =>
prisma.eventRegistration.update({
where: {
userId_eventId: {
userId: attendee.userId,
eventId,
},
},
data: {
attended: attendee.attended,
litterCollected: attendee.litterCollected,
contributionNote: attendee.contributionNote,
},
})
)
);
return updates;
},
};

View File

@@ -97,6 +97,17 @@ export class UserService {
organizedEvents: true,
},
},
eventRegistrations: {
where: {
attended: true,
litterCollected: {
not: null,
},
},
select: {
litterCollected: true,
},
},
},
});
@@ -116,11 +127,17 @@ export class UserService {
}
}
// Calculate total litter collected across all attended events
const totalLitterCollected = user.eventRegistrations.reduce((sum, reg) => {
return sum + (reg.litterCollected ? Number(reg.litterCollected) : 0);
}, 0);
return {
...user,
areasOfInterest,
reportCount: user._count.reports,
eventCount: user._count.eventRegistrations,
totalLitterCollected,
stats: {
reportsSubmitted: user._count.reports,
eventsAttended: user._count.eventRegistrations,