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:
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `event_registrations`
|
||||
ADD COLUMN `litter_collected` DECIMAL(10, 2) NULL,
|
||||
ADD COLUMN `contribution_note` TEXT NULL;
|
||||
@@ -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")
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
57
server/src/scripts/applyMigration.ts
Normal file
57
server/src/scripts/applyMigration.ts
Normal 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);
|
||||
});
|
||||
89
server/src/scripts/testAttendees.ts
Normal file
89
server/src/scripts/testAttendees.ts
Normal 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);
|
||||
});
|
||||
43
server/src/scripts/verifyMigration.ts
Normal file
43
server/src/scripts/verifyMigration.ts
Normal 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));
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user