diff --git a/backend/__tests__/events.test.ts b/backend/__tests__/events.test.ts new file mode 100644 index 0000000..aa324f4 --- /dev/null +++ b/backend/__tests__/events.test.ts @@ -0,0 +1,232 @@ +import mongoose from 'mongoose'; +import request from 'supertest'; +import express from 'express'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { jest, describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals'; +import Event from '../src/models/Event'; +import eventRoutes from '../src/routes/events'; + +// Mock auth middleware +jest.mock('../src/middleware/auth', () => ({ + checkAuth: (req: any, res: any, next: any) => next(), +})); + +describe('Events API', () => { + let app: express.Application; + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + // Set up MongoDB Memory Server + mongoServer = await MongoMemoryServer.create(); + const uri = mongoServer.getUri(); + await mongoose.connect(uri); + + // Set up Express app + app = express(); + app.use(express.json()); + app.use('/api/events', eventRoutes); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + // Clear the database before each test + await Event.deleteMany({}); + }); + + // Sample event data + const sampleEvent = { + title: 'Test Event', + description: 'This is a test event', + date: new Date('2025-05-15'), + time: '18:00 - 20:00', + location: 'Test Location', + category: 'social', + status: 'published', + registrationRequired: true, + maxAttendees: 50, + }; + + describe('GET /api/events', () => { + it('should return an empty array when no events exist', async () => { + const response = await request(app).get('/api/events'); + expect(response.status).toBe(200); + expect(response.body.events).toEqual([]); + expect(response.body.pagination.total).toBe(0); + }); + + it('should return events when they exist', async () => { + // Create a test event + await Event.create(sampleEvent); + + const response = await request(app).get('/api/events'); + expect(response.status).toBe(200); + expect(response.body.events.length).toBe(1); + expect(response.body.events[0].title).toBe('Test Event'); + expect(response.body.pagination.total).toBe(1); + }); + + it('should filter events by status', async () => { + // Create published event + await Event.create(sampleEvent); + + // Create draft event + await Event.create({ + ...sampleEvent, + title: 'Draft Event', + status: 'draft', + }); + + // Test filtering by published status + const publishedResponse = await request(app).get('/api/events?status=published'); + expect(publishedResponse.status).toBe(200); + expect(publishedResponse.body.events.length).toBe(1); + expect(publishedResponse.body.events[0].title).toBe('Test Event'); + + // Test filtering by draft status + const draftResponse = await request(app).get('/api/events?status=draft'); + expect(draftResponse.status).toBe(200); + expect(draftResponse.body.events.length).toBe(1); + expect(draftResponse.body.events[0].title).toBe('Draft Event'); + }); + }); + + describe('GET /api/events/:id', () => { + it('should return 404 for non-existent event', async () => { + const nonExistentId = new mongoose.Types.ObjectId(); + const response = await request(app).get(`/api/events/${nonExistentId}`); + expect(response.status).toBe(404); + }); + + it('should return the event when it exists', async () => { + // Create a test event + const createdEvent = await Event.create(sampleEvent); + + const response = await request(app).get(`/api/events/${createdEvent._id}`); + expect(response.status).toBe(200); + expect(response.body.event.title).toBe('Test Event'); + }); + }); + + describe('POST /api/events', () => { + it('should create a new event', async () => { + const response = await request(app) + .post('/api/events') + .send(sampleEvent); + + expect(response.status).toBe(201); + expect(response.body.event.title).toBe('Test Event'); + + // Verify event was saved to database + const events = await Event.find(); + expect(events.length).toBe(1); + expect(events[0].title).toBe('Test Event'); + }); + + it('should return 400 for invalid event data', async () => { + // Missing required fields + const invalidEvent = { + title: 'Invalid Event', + // Missing description, date, time, location, category + }; + + const response = await request(app) + .post('/api/events') + .send(invalidEvent); + + expect(response.status).toBe(400); + expect(response.body.error).toBeDefined(); + }); + }); + + describe('PUT /api/events/:id', () => { + it('should update an existing event', async () => { + // Create a test event + const createdEvent = await Event.create(sampleEvent); + + // Update the event + const updatedData = { + title: 'Updated Event Title', + description: 'Updated description', + }; + + const response = await request(app) + .put(`/api/events/${createdEvent._id}`) + .send(updatedData); + + expect(response.status).toBe(200); + expect(response.body.event.title).toBe('Updated Event Title'); + expect(response.body.event.description).toBe('Updated description'); + + // Verify event was updated in database + const updatedEvent = await Event.findById(createdEvent._id); + expect(updatedEvent?.title).toBe('Updated Event Title'); + expect(updatedEvent?.description).toBe('Updated description'); + }); + + it('should return 404 for non-existent event', async () => { + const nonExistentId = new mongoose.Types.ObjectId(); + const response = await request(app) + .put(`/api/events/${nonExistentId}`) + .send({ title: 'Updated Title' }); + + expect(response.status).toBe(404); + }); + }); + + describe('DELETE /api/events/:id', () => { + it('should delete an existing event', async () => { + // Create a test event + const createdEvent = await Event.create(sampleEvent); + + const response = await request(app).delete(`/api/events/${createdEvent._id}`); + expect(response.status).toBe(200); + + // Verify event was deleted from database + const events = await Event.find(); + expect(events.length).toBe(0); + }); + + it('should return 404 for non-existent event', async () => { + const nonExistentId = new mongoose.Types.ObjectId(); + const response = await request(app).delete(`/api/events/${nonExistentId}`); + expect(response.status).toBe(404); + }); + }); + + describe('POST /api/events/:id/register', () => { + it('should register for an event', async () => { + // Create a test event + const createdEvent = await Event.create(sampleEvent); + + const response = await request(app) + .post(`/api/events/${createdEvent._id}/register`) + .send({ userId: new mongoose.Types.ObjectId() }); + + expect(response.status).toBe(200); + + // Verify registration count was incremented + const updatedEvent = await Event.findById(createdEvent._id); + expect(updatedEvent?.registeredCount).toBe(1); + }); + + it('should return 400 when event is at capacity', async () => { + // Create a test event at capacity + const eventAtCapacity = await Event.create({ + ...sampleEvent, + maxAttendees: 5, + registeredCount: 5, + }); + + const response = await request(app) + .post(`/api/events/${eventAtCapacity._id}/register`) + .send({ userId: new mongoose.Types.ObjectId() }); + + expect(response.status).toBe(400); + expect(response.body.error.message).toContain('capacity'); + }); + }); +}); diff --git a/backend/__tests__/setup.ts b/backend/__tests__/setup.ts new file mode 100644 index 0000000..03afbaf --- /dev/null +++ b/backend/__tests__/setup.ts @@ -0,0 +1,25 @@ +// Global test setup +import mongoose from 'mongoose'; +import { jest, afterAll } from '@jest/globals'; + +// Increase timeout for tests +jest.setTimeout(30000); + +// Silence console logs during tests +global.console = { + ...console, + log: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + // Keep error and warn for debugging + error: console.error, + warn: console.warn, +}; + +// Clean up after all tests +afterAll(async () => { + // Close mongoose connection if open + if (mongoose.connection.readyState !== 0) { + await mongoose.disconnect(); + } +}); diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..4f2ac8a --- /dev/null +++ b/backend/jest.config.js @@ -0,0 +1,25 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, + testMatch: ['**/__tests__/**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/index.ts', + ], + coverageDirectory: 'coverage', + verbose: true, + setupFilesAfterEnv: ['./__tests__/setup.ts'], +}; diff --git a/backend/package.json b/backend/package.json index b3e18e4..6c8fd0a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "start": "node dist/index.js", "dev": "tsx --watch src/index.ts", "build": "tsc", - "lint": "eslint src" + "lint": "eslint src", + "test": "jest" }, "dependencies": { "bcrypt": "^5.1.1", @@ -27,13 +28,19 @@ "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jest": "^29.5.12", "@types/jsonwebtoken": "^9.0.5", "@types/morgan": "^1.9.9", "@types/node": "^20.11.14", "@types/nodemailer": "^6.4.14", + "@types/supertest": "^6.0.2", "@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.56.0", + "jest": "^29.7.0", + "mongodb-memory-server": "^9.1.6", + "supertest": "^6.3.4", + "ts-jest": "^29.1.2", "tsx": "^4.7.0", "typescript": "^5.8.0" } diff --git a/backend/src/controllers/eventController.ts b/backend/src/controllers/eventController.ts new file mode 100644 index 0000000..5a356f0 --- /dev/null +++ b/backend/src/controllers/eventController.ts @@ -0,0 +1,410 @@ +import { Request, Response } from 'express'; +import Event, { IEvent } from '../models/Event'; +import mongoose from 'mongoose'; + +/** + * Get all events with optional filtering + */ +export const getEvents = async (req: Request, res: Response) => { + try { + const { + status, + category, + search, + startDate, + endDate, + page = 1, + limit = 10 + } = req.query; + + // Build query + const query: any = {}; + + // Filter by status if provided + if (status && status !== 'all') { + query.status = status; + } + + // Filter by category if provided + if (category && category !== 'all') { + query.category = category; + } + + // Filter by date range if provided + if (startDate || endDate) { + query.date = {}; + if (startDate) { + query.date.$gte = new Date(startDate as string); + } + if (endDate) { + query.date.$lte = new Date(endDate as string); + } + } + + // Text search if provided + if (search) { + query.$text = { $search: search as string }; + } + + // Calculate pagination + const skip = ((Number(page) || 1) - 1) * (Number(limit) || 10); + + // Execute query with pagination + const events = await Event.find(query) + .sort({ date: 1 }) // Sort by date ascending + .skip(skip) + .limit(Number(limit)); + + // Get total count for pagination + const total = await Event.countDocuments(query); + + return res.status(200).json({ + events, + pagination: { + total, + page: Number(page), + limit: Number(limit), + pages: Math.ceil(total / Number(limit)) + } + }); + } catch (error) { + console.error('Error fetching events:', error); + return res.status(500).json({ + error: { + message: 'Failed to fetch events' + } + }); + } +}; + +/** + * Get a single event by ID + */ +export const getEventById = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + // Validate ID format + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + error: { + message: 'Invalid event ID format' + } + }); + } + + // Find event + const event = await Event.findById(id); + + // Check if event exists + if (!event) { + return res.status(404).json({ + error: { + message: 'Event not found' + } + }); + } + + return res.status(200).json({ event }); + } catch (error) { + console.error('Error fetching event:', error); + return res.status(500).json({ + error: { + message: 'Failed to fetch event' + } + }); + } +}; + +/** + * Create a new event + */ +export const createEvent = async (req: Request, res: Response) => { + try { + const { + title, + description, + date, + time, + location, + category, + status, + registrationRequired, + maxAttendees, + recurring, + recurrencePattern, + tags + } = req.body; + + // Create new event + const event = new Event({ + title, + description, + date, + time, + location, + category, + status: status || 'draft', + registrationRequired: registrationRequired || false, + registeredCount: 0, + maxAttendees, + recurring: recurring || false, + recurrencePattern, + tags + }); + + // Save event to database + await event.save(); + + return res.status(201).json({ + message: 'Event created successfully', + event + }); + } catch (error) { + console.error('Error creating event:', error); + + // Handle validation errors + if (error instanceof mongoose.Error.ValidationError) { + return res.status(400).json({ + error: { + message: 'Validation error', + details: error.errors + } + }); + } + + return res.status(500).json({ + error: { + message: 'Failed to create event' + } + }); + } +}; + +/** + * Update an existing event + */ +export const updateEvent = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const updateData = req.body; + + // Validate ID format + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + error: { + message: 'Invalid event ID format' + } + }); + } + + // Find and update event + const event = await Event.findByIdAndUpdate( + id, + updateData, + { new: true, runValidators: true } + ); + + // Check if event exists + if (!event) { + return res.status(404).json({ + error: { + message: 'Event not found' + } + }); + } + + return res.status(200).json({ + message: 'Event updated successfully', + event + }); + } catch (error) { + console.error('Error updating event:', error); + + // Handle validation errors + if (error instanceof mongoose.Error.ValidationError) { + return res.status(400).json({ + error: { + message: 'Validation error', + details: error.errors + } + }); + } + + return res.status(500).json({ + error: { + message: 'Failed to update event' + } + }); + } +}; + +/** + * Delete an event + */ +export const deleteEvent = async (req: Request, res: Response) => { + try { + const { id } = req.params; + + // Validate ID format + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + error: { + message: 'Invalid event ID format' + } + }); + } + + // Find and delete event + const event = await Event.findByIdAndDelete(id); + + // Check if event exists + if (!event) { + return res.status(404).json({ + error: { + message: 'Event not found' + } + }); + } + + return res.status(200).json({ + message: 'Event deleted successfully' + }); + } catch (error) { + console.error('Error deleting event:', error); + return res.status(500).json({ + error: { + message: 'Failed to delete event' + } + }); + } +}; + +/** + * Register for an event + */ +export const registerForEvent = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { userId } = req.body; + + // Validate ID format + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + error: { + message: 'Invalid event ID format' + } + }); + } + + // Find event + const event = await Event.findById(id); + + // Check if event exists + if (!event) { + return res.status(404).json({ + error: { + message: 'Event not found' + } + }); + } + + // Check if registration is required + if (!event.registrationRequired) { + return res.status(400).json({ + error: { + message: 'Registration is not required for this event' + } + }); + } + + // Check if event is at capacity + if (event.maxAttendees && event.registeredCount >= event.maxAttendees) { + return res.status(400).json({ + error: { + message: 'Event is at full capacity' + } + }); + } + + // Increment registered count + event.registeredCount += 1; + await event.save(); + + // TODO: In a real implementation, we would also save the user's registration + // in a separate EventRegistration collection + + return res.status(200).json({ + message: 'Successfully registered for event', + event + }); + } catch (error) { + console.error('Error registering for event:', error); + return res.status(500).json({ + error: { + message: 'Failed to register for event' + } + }); + } +}; + +/** + * Cancel registration for an event + */ +export const cancelRegistration = async (req: Request, res: Response) => { + try { + const { id } = req.params; + const { userId } = req.body; + + // Validate ID format + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ + error: { + message: 'Invalid event ID format' + } + }); + } + + // Find event + const event = await Event.findById(id); + + // Check if event exists + if (!event) { + return res.status(404).json({ + error: { + message: 'Event not found' + } + }); + } + + // Check if registration is required + if (!event.registrationRequired) { + return res.status(400).json({ + error: { + message: 'Registration is not required for this event' + } + }); + } + + // Decrement registered count (ensure it doesn't go below 0) + if (event.registeredCount > 0) { + event.registeredCount -= 1; + await event.save(); + } + + // TODO: In a real implementation, we would also remove the user's registration + // from a separate EventRegistration collection + + return res.status(200).json({ + message: 'Successfully cancelled registration', + event + }); + } catch (error) { + console.error('Error cancelling registration:', error); + return res.status(500).json({ + error: { + message: 'Failed to cancel registration' + } + }); + } +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 671377a..e9bc0dc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,11 +9,11 @@ import { rateLimit } from 'express-rate-limit'; // Load environment variables dotenv.config(); -// Import routes (will be implemented later) +// Import routes // import authRoutes from './routes/auth'; -// import eventRoutes from './routes/events'; +import eventRoutes from './routes/events'; // import memberRoutes from './routes/members'; -// import videoRoutes from './routes/videos'; +import videoRoutes from './routes/videos'; // Create Express app const app = express(); @@ -64,9 +64,9 @@ app.get('/health', (req, res) => { // API routes // app.use('/api/auth', authRoutes); -// app.use('/api/events', eventRoutes); +app.use('/api/events', eventRoutes); // app.use('/api/members', memberRoutes); -// app.use('/api/videos', videoRoutes); +app.use('/api/videos', videoRoutes); // Error handling middleware app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { diff --git a/backend/src/models/Event.ts b/backend/src/models/Event.ts new file mode 100644 index 0000000..9a1140b --- /dev/null +++ b/backend/src/models/Event.ts @@ -0,0 +1,97 @@ +import mongoose, { Schema, Document } from 'mongoose'; + +// Event document interface +export interface IEvent extends Document { + title: string; + description: string; + date: Date; + time: string; + location: string; + category: 'social' | 'athletic' | 'board' | 'general' | 'educational'; + status: 'published' | 'draft' | 'archived'; + registrationRequired: boolean; + registeredCount: number; + maxAttendees?: number; + recurring?: boolean; + recurrencePattern?: string; + organizer?: mongoose.Types.ObjectId; + tags?: string[]; +} + +// Event schema +const EventSchema: Schema = new Schema({ + title: { + type: String, + required: true, + trim: true, + minLength: 3 + }, + description: { + type: String, + required: true, + trim: true + }, + date: { + type: Date, + required: true, + index: true + }, + time: { + type: String, + required: true, + trim: true + }, + location: { + type: String, + required: true, + trim: true + }, + category: { + type: String, + required: true, + enum: ['social', 'athletic', 'board', 'general', 'educational'], + index: true + }, + status: { + type: String, + required: true, + enum: ['published', 'draft', 'archived'], + default: 'draft', + index: true + }, + registrationRequired: { + type: Boolean, + default: false + }, + registeredCount: { + type: Number, + default: 0 + }, + maxAttendees: { + type: Number + }, + recurring: { + type: Boolean, + default: false + }, + recurrencePattern: { + type: String + }, + organizer: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + tags: [{ + type: String, + trim: true + }] +}, { + timestamps: true +}); + +// Create indexes for performance +EventSchema.index({ title: 'text', description: 'text', location: 'text' }); // Full-text search +EventSchema.index({ date: 1, category: 1 }); // Date and category filtering +EventSchema.index({ status: 1, date: 1 }); // Status and date filtering + +export default mongoose.model('Event', EventSchema); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts new file mode 100644 index 0000000..36e6a71 --- /dev/null +++ b/backend/src/routes/events.ts @@ -0,0 +1,20 @@ +import express from 'express'; +import { checkAuth } from '../middleware/auth'; +import * as eventController from '../controllers/eventController'; + +const router = express.Router(); + +// Public routes +router.get('/', eventController.getEvents); +router.get('/:id', eventController.getEventById); + +// Protected routes (admin only) +router.post('/', checkAuth, eventController.createEvent); +router.put('/:id', checkAuth, eventController.updateEvent); +router.delete('/:id', checkAuth, eventController.deleteEvent); + +// Registration routes +router.post('/:id/register', checkAuth, eventController.registerForEvent); +router.post('/:id/cancel-registration', checkAuth, eventController.cancelRegistration); + +export default router; diff --git a/cline_docs/activeContext.md b/cline_docs/activeContext.md index be2101c..bb15476 100644 --- a/cline_docs/activeContext.md +++ b/cline_docs/activeContext.md @@ -17,6 +17,16 @@ - Created content management with WYSIWYG editor preview - Added settings interface with site, email, and membership options +- Implemented full Events Management System: + - Created Event model and API endpoints in the backend + - Implemented EventController with CRUD operations + - Built useEvents hook for frontend data management + - Updated events listing page to use real data with filtering + - Created event creation form with validation + - Implemented event editing functionality + - Added event details view page + - Implemented comprehensive test suite for Events API + - Major improvements to frontend architecture: - Implemented responsive layouts for all admin interfaces - Used TypeScript for strongly-typed components @@ -33,13 +43,15 @@ ## Next Steps 1. Complete backend API integrations: - - Connect admin UI components to backend endpoints - - Add real data loading with loading states - - Implement error handling for API requests - - Set up client-side data validation + - Connect admin UI components to backend endpoints (✓ Events management implemented) + - Add real data loading with loading states (✓ Implemented for Events) + - Implement error handling for API requests (✓ Implemented for Events) + - Set up client-side data validation (✓ Implemented for Events) 2. Implement remaining admin features: - - Add form handlers for CRUD operations - - Implement event recurrence functionality + - Add form handlers for CRUD operations (✓ Implemented for Events) + - Connect Members management to backend API + - Connect Videos management to backend API + - Connect Documents management to backend API - Create media upload components - Add user notification system 3. Deploy staging environment: diff --git a/cline_docs/progress.md b/cline_docs/progress.md index 64090b2..fd3558d 100644 --- a/cline_docs/progress.md +++ b/cline_docs/progress.md @@ -64,10 +64,15 @@ ## In Progress Features ### Phase 4: API Integration & Backend Functionality -- [ ] Connect admin interfaces to backend API endpoints -- [ ] Implement real data loading with state management -- [ ] Create API error handling and recovery -- [ ] Develop form submissions with validation +- [x] Connect admin interfaces to backend API endpoints (Events management) +- [x] Implement real data loading with state management (Events) +- [x] Create API error handling and recovery (Events) +- [x] Develop form submissions with validation (Events) +- [x] Implement event creation and editing forms +- [x] Create event details view page +- [x] Add event deletion with confirmation +- [x] Implement comprehensive test suite for API endpoints +- [ ] Connect remaining admin interfaces to backend APIs - [ ] Build media upload functionality - [ ] Implement user notification system - [ ] Create advanced filtering for data tables @@ -122,9 +127,9 @@ - [ ] Add comprehensive JSDoc comments ## Project Stats -- **Completed Tasks:** 36 +- **Completed Tasks:** 44 - **In Progress Tasks:** 9 - **Upcoming Tasks:** 23 -- **Completion Rate:** ~53% +- **Completion Rate:** ~58% - **Current Phase:** Transitioning from Phase 3 to Phase 4 - **Next Major Milestone:** Full API integration diff --git a/cline_docs/systemPatterns.md b/cline_docs/systemPatterns.md index 9bc7427..33659d7 100644 --- a/cline_docs/systemPatterns.md +++ b/cline_docs/systemPatterns.md @@ -76,6 +76,25 @@ - Error association - Keyboard navigation +### Admin Form Patterns +- **Create/Edit/View pattern** + - Consistent page structure for entity management + - Shared form components between create and edit + - Read-only view pages with formatted data display +- **Form state management** + - Local state for form data + - Separate state for validation errors + - Loading and submission states +- **Validation strategies** + - Client-side validation before submission + - Field-level validation on change/blur + - Form-level validation on submit + - Server-side validation as fallback +- **Error recovery** + - Clear error messages + - Preservation of valid form data + - Ability to retry submission + ### Routing - **Next.js App Router** - File-based routing for pages @@ -100,6 +119,21 @@ - Services for reusable operations - Models for data access +### API Integration Patterns +- **Custom hooks for API interaction** + - Resource-specific hooks (useEvents, useMembers, etc.) + - Standardized CRUD operations + - Loading and error states + - Pagination and filtering support +- **Data transformation** + - Server-to-client format conversion + - Date formatting for display + - Consistent error handling +- **Optimistic updates** + - Update UI before server confirmation + - Rollback on error + - Loading indicators during operations + ### Authentication - **JWT-based authentication** - JWTs stored in HTTP-only cookies @@ -163,6 +197,13 @@ - Create, read, update, delete operations - Schema validation - Indexing performance +- **Admin API testing patterns** + - In-memory MongoDB for isolated tests + - Mock authentication middleware + - Comprehensive CRUD operation testing + - Filter and pagination testing + - Error case testing + - Registration functionality testing ### End-to-End Testing - **User flow testing** diff --git a/frontend/src/app/admin/events/[id]/edit/page.tsx b/frontend/src/app/admin/events/[id]/edit/page.tsx new file mode 100644 index 0000000..8848758 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/edit/page.tsx @@ -0,0 +1,602 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { useEvents, Event } from '../../../../../hooks/useEvents'; +import Link from 'next/link'; + +interface EventFormData { + title: string; + description: string; + date: string; + time: string; + location: string; + category: 'social' | 'athletic' | 'board' | 'general' | 'educational'; + status: 'published' | 'draft' | 'archived'; + registrationRequired: boolean; + maxAttendees?: number; + recurring: boolean; + recurrencePattern?: string; + tags: string; + registeredCount: number; +} + +interface EventFormErrors { + title?: string; + description?: string; + date?: string; + time?: string; + location?: string; + category?: string; + status?: string; + maxAttendees?: string; + recurrencePattern?: string; +} + +export default function EditEventPage() { + const router = useRouter(); + const params = useParams(); + const eventId = params.id as string; + + const { getEventById, updateEvent } = useEvents(); + + const [formData, setFormData] = useState({ + title: '', + description: '', + date: '', + time: '', + location: '', + category: 'social', + status: 'draft', + registrationRequired: false, + maxAttendees: undefined, + recurring: false, + recurrencePattern: '', + tags: '', + registeredCount: 0, + }); + + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(''); + const [submitError, setSubmitError] = useState(''); + + // Load event data + useEffect(() => { + const loadEvent = async () => { + setIsLoading(true); + setLoadError(''); + + try { + const event = await getEventById(eventId); + + if (event) { + // Format date for input field (YYYY-MM-DD) + const dateObj = new Date(event.date); + const formattedDate = dateObj.toISOString().split('T')[0]; + + // Convert tags array to comma-separated string + const tagsString = event.tags?.join(', ') || ''; + + setFormData({ + title: event.title, + description: event.description, + date: formattedDate, + time: event.time, + location: event.location, + category: event.category, + status: event.status, + registrationRequired: event.registrationRequired, + maxAttendees: event.maxAttendees, + recurring: event.recurring || false, + recurrencePattern: event.recurrencePattern || '', + tags: tagsString, + registeredCount: event.registeredCount, + }); + } else { + setLoadError('Event not found'); + } + } catch (error) { + console.error('Error loading event:', error); + setLoadError('Failed to load event data. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + loadEvent(); + }, [eventId, getEventById]); + + const validateForm = (): boolean => { + const newErrors: EventFormErrors = {}; + + // Title validation + if (!formData.title.trim()) { + newErrors.title = 'Title is required'; + } else if (formData.title.length < 3) { + newErrors.title = 'Title must be at least 3 characters'; + } + + // Description validation + if (!formData.description.trim()) { + newErrors.description = 'Description is required'; + } + + // Date validation + if (!formData.date) { + newErrors.date = 'Date is required'; + } + + // Time validation + if (!formData.time.trim()) { + newErrors.time = 'Time is required'; + } + + // Location validation + if (!formData.location.trim()) { + newErrors.location = 'Location is required'; + } + + // Category validation + if (!formData.category) { + newErrors.category = 'Category is required'; + } + + // Status validation + if (!formData.status) { + newErrors.status = 'Status is required'; + } + + // Max attendees validation (if registration is required) + if (formData.registrationRequired && formData.maxAttendees !== undefined) { + if (formData.maxAttendees <= 0) { + newErrors.maxAttendees = 'Maximum attendees must be greater than 0'; + } + } + + // Recurrence pattern validation (if event is recurring) + if (formData.recurring && !formData.recurrencePattern?.trim()) { + newErrors.recurrencePattern = 'Recurrence pattern is required for recurring events'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value, type } = e.target as HTMLInputElement; + + if (type === 'checkbox') { + const checked = (e.target as HTMLInputElement).checked; + setFormData({ + ...formData, + [name]: checked, + }); + } else if (name === 'maxAttendees') { + setFormData({ + ...formData, + [name]: value ? parseInt(value, 10) : undefined, + }); + } else { + setFormData({ + ...formData, + [name]: value, + }); + } + + // Clear error when user types + if (errors[name as keyof EventFormErrors]) { + setErrors({ + ...errors, + [name]: undefined, + }); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + setSubmitError(''); + + try { + // Convert form data to the format expected by the API + const eventData = { + ...formData, + tags: formData.tags.split(',').map(tag => tag.trim()).filter(tag => tag), + date: new Date(formData.date).toISOString(), + }; + + const result = await updateEvent(eventId, eventData); + + if (result) { + // Redirect to events list page on success + router.push('/admin/events'); + } else { + setSubmitError('Failed to update event. Please try again.'); + } + } catch (error) { + console.error('Error updating event:', error); + setSubmitError('An unexpected error occurred. Please try again later.'); + } finally { + setIsSubmitting(false); + } + }; + + if (isLoading) { + return ( +
+
+
+ Loading event data... +
+
+ ); + } + + if (loadError) { + return ( +
+
+

Error

+

{loadError}

+
+ + Return to Events + +
+
+
+ ); + } + + return ( +
+
+

Edit Event

+ + Cancel + +
+ +
+ {/* Error message */} + {submitError && ( +
+

Error

+

{submitError}

+
+ )} + +
+ {/* Title field */} +
+ + + {errors.title && ( +

+ {errors.title} +

+ )} +
+ + {/* Description field */} +
+ + + {errors.description && ( +

+ {errors.description} +

+ )} +
+ + {/* Date and Time fields */} +
+
+ + + {errors.date && ( +

+ {errors.date} +

+ )} +
+ +
+ + + {errors.time && ( +

+ {errors.time} +

+ )} +
+
+ + {/* Location field */} +
+ + + {errors.location && ( +

+ {errors.location} +

+ )} +
+ + {/* Category and Status fields */} +
+
+ + + {errors.category && ( +

+ {errors.category} +

+ )} +
+ +
+ + + {errors.status && ( +

+ {errors.status} +

+ )} +
+
+ + {/* Registration fields */} +
+
+ + +
+ + {formData.registrationRequired && ( +
+ + + {errors.maxAttendees && ( +

+ {errors.maxAttendees} +

+ )} +
+ )} + + {formData.registrationRequired && ( +
+ + +

+ Number of people currently registered for this event. +

+
+ )} +
+ + {/* Recurring event fields */} +
+
+ + +
+ + {formData.recurring && ( +
+ + + {errors.recurrencePattern && ( +

+ {errors.recurrencePattern} +

+ )} +
+ )} +
+ + {/* Tags field */} +
+ + +
+ + {/* Submit button */} +
+ + Cancel + + +
+ +

+ * Required fields +

+
+
+
+ ); +} diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx new file mode 100644 index 0000000..57e6ec6 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -0,0 +1,265 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useEvents } from '../../../../hooks/useEvents'; + +export default function EventDetailsPage() { + const params = useParams(); + const router = useRouter(); + const eventId = params.id as string; + + const { getEventById, deleteEvent } = useEvents(); + + const [event, setEvent] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); + + useEffect(() => { + const loadEvent = async () => { + setIsLoading(true); + setError(''); + + try { + const eventData = await getEventById(eventId); + + if (eventData) { + setEvent(eventData); + } else { + setError('Event not found'); + } + } catch (err) { + console.error('Error loading event:', err); + setError('Failed to load event data. Please try again.'); + } finally { + setIsLoading(false); + } + }; + + loadEvent(); + }, [eventId, getEventById]); + + const handleDelete = async () => { + if (!window.confirm('Are you sure you want to delete this event?')) { + return; + } + + setIsDeleting(true); + + try { + const success = await deleteEvent(eventId); + + if (success) { + router.push('/admin/events'); + } else { + setError('Failed to delete event. Please try again.'); + } + } catch (err) { + console.error('Error deleting event:', err); + setError('An unexpected error occurred. Please try again later.'); + } finally { + setIsDeleting(false); + } + }; + + if (isLoading) { + return ( +
+
+
+ Loading event data... +
+
+ ); + } + + if (error) { + return ( +
+
+

Error

+

{error}

+
+ + Return to Events + +
+
+
+ ); + } + + if (!event) { + return null; + } + + // Format date for display + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric' + }); + }; + + return ( +
+
+

{event.title}

+
+ + Back to Events + + + Edit Event + +
+
+ +
+
+
+ {/* Event details */} +
+

Event Details

+ +
+

{event.description}

+
+
+ + {/* Tags */} + {event.tags && event.tags.length > 0 && ( +
+

Tags

+
+ {event.tags.map((tag: string, index: number) => ( + + {tag} + + ))} +
+
+ )} + + {/* Recurring info */} + {event.recurring && ( +
+

Recurrence

+

{event.recurrencePattern}

+
+ )} +
+ +
+ {/* Event metadata */} +
+

Event Information

+ +
+
+

Date

+

{formatDate(event.date)}

+
+ +
+

Time

+

{event.time}

+
+ +
+

Location

+

{event.location}

+
+ +
+

Category

+

{event.category}

+
+ +
+

Status

+ + {event.status.charAt(0).toUpperCase() + event.status.slice(1)} + +
+
+
+ + {/* Registration info */} +
+

Registration

+ + {event.registrationRequired ? ( +
+

Registration is required for this event.

+ +
+

Current Registrations

+

{event.registeredCount}

+
+ + {event.maxAttendees && ( +
+

Maximum Attendees

+

{event.maxAttendees}

+
+ )} + + {event.maxAttendees && ( +
+
+
+
+

+ {event.registeredCount} of {event.maxAttendees} spots filled + ({Math.round((event.registeredCount / event.maxAttendees) * 100)}%) +

+
+ )} +
+ ) : ( +

Registration is not required for this event.

+ )} +
+ + {/* Danger zone */} +
+

Danger Zone

+

+ Deleting this event will permanently remove it and cannot be undone. +

+ +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/admin/events/create/page.tsx b/frontend/src/app/admin/events/create/page.tsx new file mode 100644 index 0000000..0effbe9 --- /dev/null +++ b/frontend/src/app/admin/events/create/page.tsx @@ -0,0 +1,510 @@ +'use client'; + +import React, { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useEvents } from '../../../../hooks/useEvents'; +import Link from 'next/link'; + +interface EventFormData { + title: string; + description: string; + date: string; + time: string; + location: string; + category: 'social' | 'athletic' | 'board' | 'general' | 'educational'; + status: 'published' | 'draft' | 'archived'; + registrationRequired: boolean; + maxAttendees?: number; + recurring: boolean; + recurrencePattern?: string; + tags: string; +} + +interface EventFormErrors { + title?: string; + description?: string; + date?: string; + time?: string; + location?: string; + category?: string; + status?: string; + maxAttendees?: string; + recurrencePattern?: string; +} + +export default function CreateEventPage() { + const router = useRouter(); + const { createEvent } = useEvents(); + + const [formData, setFormData] = useState({ + title: '', + description: '', + date: '', + time: '', + location: '', + category: 'social', + status: 'draft', + registrationRequired: false, + maxAttendees: undefined, + recurring: false, + recurrencePattern: '', + tags: '', + }); + + const [errors, setErrors] = useState({}); + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(''); + + const validateForm = (): boolean => { + const newErrors: EventFormErrors = {}; + + // Title validation + if (!formData.title.trim()) { + newErrors.title = 'Title is required'; + } else if (formData.title.length < 3) { + newErrors.title = 'Title must be at least 3 characters'; + } + + // Description validation + if (!formData.description.trim()) { + newErrors.description = 'Description is required'; + } + + // Date validation + if (!formData.date) { + newErrors.date = 'Date is required'; + } else { + const selectedDate = new Date(formData.date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (selectedDate < today) { + newErrors.date = 'Date cannot be in the past'; + } + } + + // Time validation + if (!formData.time.trim()) { + newErrors.time = 'Time is required'; + } + + // Location validation + if (!formData.location.trim()) { + newErrors.location = 'Location is required'; + } + + // Category validation + if (!formData.category) { + newErrors.category = 'Category is required'; + } + + // Status validation + if (!formData.status) { + newErrors.status = 'Status is required'; + } + + // Max attendees validation (if registration is required) + if (formData.registrationRequired && formData.maxAttendees !== undefined) { + if (formData.maxAttendees <= 0) { + newErrors.maxAttendees = 'Maximum attendees must be greater than 0'; + } + } + + // Recurrence pattern validation (if event is recurring) + if (formData.recurring && !formData.recurrencePattern?.trim()) { + newErrors.recurrencePattern = 'Recurrence pattern is required for recurring events'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleChange = ( + e: React.ChangeEvent + ) => { + const { name, value, type } = e.target as HTMLInputElement; + + if (type === 'checkbox') { + const checked = (e.target as HTMLInputElement).checked; + setFormData({ + ...formData, + [name]: checked, + }); + } else if (name === 'maxAttendees') { + setFormData({ + ...formData, + [name]: value ? parseInt(value, 10) : undefined, + }); + } else { + setFormData({ + ...formData, + [name]: value, + }); + } + + // Clear error when user types + if (errors[name as keyof EventFormErrors]) { + setErrors({ + ...errors, + [name]: undefined, + }); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + setSubmitError(''); + + try { + // Convert form data to the format expected by the API + const eventData = { + ...formData, + tags: formData.tags.split(',').map(tag => tag.trim()).filter(tag => tag), + date: new Date(formData.date).toISOString(), + registeredCount: 0, // Initialize with zero registrations + }; + + const result = await createEvent(eventData); + + if (result) { + // Redirect to events list page on success + router.push('/admin/events'); + } else { + setSubmitError('Failed to create event. Please try again.'); + } + } catch (error) { + console.error('Error creating event:', error); + setSubmitError('An unexpected error occurred. Please try again later.'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+

Create New Event

+ + Cancel + +
+ +
+ {/* Error message */} + {submitError && ( +
+

Error

+

{submitError}

+
+ )} + +
+ {/* Title field */} +
+ + + {errors.title && ( +

+ {errors.title} +

+ )} +
+ + {/* Description field */} +
+ + + {errors.description && ( +

+ {errors.description} +

+ )} +
+ + {/* Date and Time fields */} +
+
+ + + {errors.date && ( +

+ {errors.date} +

+ )} +
+ +
+ + + {errors.time && ( +

+ {errors.time} +

+ )} +
+
+ + {/* Location field */} +
+ + + {errors.location && ( +

+ {errors.location} +

+ )} +
+ + {/* Category and Status fields */} +
+
+ + + {errors.category && ( +

+ {errors.category} +

+ )} +
+ +
+ + + {errors.status && ( +

+ {errors.status} +

+ )} +
+
+ + {/* Registration fields */} +
+
+ + +
+ + {formData.registrationRequired && ( +
+ + + {errors.maxAttendees && ( +

+ {errors.maxAttendees} +

+ )} +
+ )} +
+ + {/* Recurring event fields */} +
+
+ + +
+ + {formData.recurring && ( +
+ + + {errors.recurrencePattern && ( +

+ {errors.recurrencePattern} +

+ )} +
+ )} +
+ + {/* Tags field */} +
+ + +
+ + {/* Submit button */} +
+ + Cancel + + +
+ +

+ * Required fields +

+
+
+
+ ); +} diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx index 1c889fa..be84fc5 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -1,7 +1,8 @@ 'use client'; -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import Link from 'next/link'; +import { useEvents, Event as EventType, EventFilter as EventFilterType } from '../../../hooks/useEvents'; // Simple Filter Component const EventFilter = ({ @@ -60,6 +61,7 @@ const EventFilter = ({ + @@ -101,84 +103,44 @@ const EventFilter = ({ export default function EventsPage() { const [filter, setFilter] = useState({ status: 'all', category: 'all', search: '' }); - // Mock events data - would come from API in a real implementation - const allEvents = [ - { - id: '1', - title: 'Monthly Social', - date: 'Apr 15, 2025', - time: '6:00 PM - 9:00 PM', - location: 'Olathe Community Center', - category: 'social', - status: 'published', - registrationRequired: true, - registeredCount: 24 - }, - { - id: '2', - title: 'ASL Workshop', - date: 'Apr 22, 2025', - time: '10:00 AM - 12:00 PM', - location: 'OCD Meeting Room', - category: 'educational', - status: 'draft', - registrationRequired: true, - registeredCount: 12 - }, - { - id: '3', - title: 'Board Meeting', - date: 'May 5, 2025', - time: '7:00 PM - 8:30 PM', - location: 'Zoom Meeting', - category: 'board', - status: 'published', - registrationRequired: false, - registeredCount: 0 - }, - { - id: '4', - title: 'Summer Picnic', - date: 'Jun 12, 2025', - time: '12:00 PM - 4:00 PM', - location: 'Olathe Park', - category: 'social', - status: 'draft', - registrationRequired: true, - registeredCount: 0 - }, - { - id: '5', - title: 'Deaf Awareness Week Workshop', - date: 'Sep 23, 2025', - time: '6:30 PM - 8:30 PM', - location: 'Olathe Public Library', - category: 'educational', - status: 'published', - registrationRequired: true, - registeredCount: 8 - }, - { - id: '6', - title: 'Holiday Party', - date: 'Dec 18, 2025', - time: '7:00 PM - 10:00 PM', - location: 'Olathe Community Center', - category: 'social', - status: 'draft', - registrationRequired: true, - registeredCount: 0 - }, - ]; + // Use the events hook to fetch and manage events + const { + events, + loading, + error, + pagination, + fetchEvents, + deleteEvent + } = useEvents(); - // Filter events based on current filter settings - const filteredEvents = allEvents.filter(event => { - if (filter.status !== 'all' && event.status !== filter.status) return false; - if (filter.category !== 'all' && event.category !== filter.category) return false; - if (filter.search && !event.title.toLowerCase().includes(filter.search.toLowerCase()) && - !event.location.toLowerCase().includes(filter.search.toLowerCase())) return false; - return true; - }); + // Apply filters when the filter state changes + useEffect(() => { + const apiFilter: EventFilterType = {}; + + if (filter.status !== 'all') { + apiFilter.status = filter.status; + } + + if (filter.category !== 'all') { + apiFilter.category = filter.category; + } + + if (filter.search) { + apiFilter.search = filter.search; + } + + fetchEvents(apiFilter); + }, [filter, fetchEvents]); + + // Format date for display + const formatDate = (dateString: string) => { + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric' + }); + }; // Handle filter changes const handleFilterChange = (newFilter: { status: string; category: string; search: string }) => { @@ -267,65 +229,96 @@ export default function EventsPage() { - {filteredEvents.map((event) => ( - - -
-
-
{event.title}
-
- - {event.category.charAt(0).toUpperCase() + event.category.slice(1)} - -
-
-
- - -
{event.date}
-
{event.time}
- - -
{event.location}
- - - - {event.status.charAt(0).toUpperCase() + event.status.slice(1)} - - - - {event.registrationRequired ? ( - {event.registeredCount} registered - ) : ( - Not required - )} - - -
- - View - - - Edit - - + {loading ? ( + + +
+
+

Loading events...

- ))} + ) : error ? ( + + +

Error loading events. Please try again.

+ + + ) : events.length === 0 ? ( + + +

No events found matching the current filters.

+ + + ) : ( + events.map((event) => ( + + +
+
+
+ {event.title} +
+
+ + {event.category.charAt(0).toUpperCase() + event.category.slice(1)} + +
+
+
+ + +
{formatDate(event.date)}
+
{event.time}
+ + +
{event.location}
+ + + + {event.status.charAt(0).toUpperCase() + event.status.slice(1)} + + + + {event.registrationRequired ? ( + {event.registeredCount} registered + ) : ( + Not required + )} + + +
+ + View + + + Edit + + +
+ + + )) + )}
- {filteredEvents.length === 0 && ( + {!loading && !error && events.length === 0 && (

No events found matching the current filters.

diff --git a/frontend/src/hooks/useEvents.ts b/frontend/src/hooks/useEvents.ts new file mode 100644 index 0000000..09aeb2f --- /dev/null +++ b/frontend/src/hooks/useEvents.ts @@ -0,0 +1,256 @@ +import { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; + +// Define Event interface +export interface Event { + _id: string; + title: string; + description: string; + date: string; + time: string; + location: string; + category: 'social' | 'athletic' | 'board' | 'general' | 'educational'; + status: 'published' | 'draft' | 'archived'; + registrationRequired: boolean; + registeredCount: number; + maxAttendees?: number; + recurring?: boolean; + recurrencePattern?: string; + tags?: string[]; + createdAt: string; + updatedAt: string; +} + +// Define filter interface +export interface EventFilter { + status?: string; + category?: string; + search?: string; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; +} + +// Define pagination interface +export interface Pagination { + total: number; + page: number; + limit: number; + pages: number; +} + +// Define hook return interface +export interface UseEventsReturn { + events: Event[]; + loading: boolean; + error: Error | null; + pagination: Pagination | null; + fetchEvents: (filters?: EventFilter) => Promise; + getEventById: (id: string) => Promise; + createEvent: (eventData: Omit) => Promise; + updateEvent: (id: string, eventData: Partial) => Promise; + deleteEvent: (id: string) => Promise; + registerForEvent: (id: string) => Promise; + cancelRegistration: (id: string) => Promise; +} + +// API base URL +const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'; + +/** + * Hook for managing events data + */ +export function useEvents(): UseEventsReturn { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pagination, setPagination] = useState(null); + + /** + * Fetch events with optional filtering + */ + const fetchEvents = useCallback(async (filters?: EventFilter) => { + setLoading(true); + setError(null); + + try { + // Build query string from filters + const queryParams = new URLSearchParams(); + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== '') { + queryParams.append(key, String(value)); + } + }); + } + + const response = await axios.get(`${API_URL}/events?${queryParams.toString()}`); + setEvents(response.data.events); + setPagination(response.data.pagination); + } catch (err) { + console.error('Error fetching events:', err); + setError(err instanceof Error ? err : new Error('Failed to fetch events')); + } finally { + setLoading(false); + } + }, []); + + /** + * Get a single event by ID + */ + const getEventById = useCallback(async (id: string): Promise => { + try { + const response = await axios.get(`${API_URL}/events/${id}`); + return response.data.event; + } catch (err) { + console.error(`Error fetching event with ID ${id}:`, err); + return null; + } + }, []); + + /** + * Create a new event + */ + const createEvent = useCallback(async (eventData: Omit): Promise => { + try { + const response = await axios.post(`${API_URL}/events`, eventData, { + headers: { + 'Content-Type': 'application/json', + // Add authorization header if needed + // 'Authorization': `Bearer ${token}` + } + }); + + // Refresh events list after creating + fetchEvents(); + + return response.data.event; + } catch (err) { + console.error('Error creating event:', err); + return null; + } + }, [fetchEvents]); + + /** + * Update an existing event + */ + const updateEvent = useCallback(async (id: string, eventData: Partial): Promise => { + try { + const response = await axios.put(`${API_URL}/events/${id}`, eventData, { + headers: { + 'Content-Type': 'application/json', + // Add authorization header if needed + // 'Authorization': `Bearer ${token}` + } + }); + + // Update local state + setEvents(prevEvents => + prevEvents.map(event => + event._id === id ? { ...event, ...response.data.event } : event + ) + ); + + return response.data.event; + } catch (err) { + console.error(`Error updating event with ID ${id}:`, err); + return null; + } + }, []); + + /** + * Delete an event + */ + const deleteEvent = useCallback(async (id: string): Promise => { + try { + await axios.delete(`${API_URL}/events/${id}`, { + headers: { + // Add authorization header if needed + // 'Authorization': `Bearer ${token}` + } + }); + + // Update local state + setEvents(prevEvents => prevEvents.filter(event => event._id !== id)); + + return true; + } catch (err) { + console.error(`Error deleting event with ID ${id}:`, err); + return false; + } + }, []); + + /** + * Register for an event + */ + const registerForEvent = useCallback(async (id: string): Promise => { + try { + const response = await axios.post(`${API_URL}/events/${id}/register`, {}, { + headers: { + 'Content-Type': 'application/json', + // Add authorization header if needed + // 'Authorization': `Bearer ${token}` + } + }); + + // Update local state + setEvents(prevEvents => + prevEvents.map(event => + event._id === id ? { ...event, ...response.data.event } : event + ) + ); + + return response.data.event; + } catch (err) { + console.error(`Error registering for event with ID ${id}:`, err); + return null; + } + }, []); + + /** + * Cancel registration for an event + */ + const cancelRegistration = useCallback(async (id: string): Promise => { + try { + const response = await axios.post(`${API_URL}/events/${id}/cancel-registration`, {}, { + headers: { + 'Content-Type': 'application/json', + // Add authorization header if needed + // 'Authorization': `Bearer ${token}` + } + }); + + // Update local state + setEvents(prevEvents => + prevEvents.map(event => + event._id === id ? { ...event, ...response.data.event } : event + ) + ); + + return response.data.event; + } catch (err) { + console.error(`Error cancelling registration for event with ID ${id}:`, err); + return null; + } + }, []); + + // Load events on initial render + useEffect(() => { + fetchEvents(); + }, [fetchEvents]); + + return { + events, + loading, + error, + pagination, + fetchEvents, + getEventById, + createEvent, + updateEvent, + deleteEvent, + registerForEvent, + cancelRegistration + }; +}