diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 4f692c5..b28aa14 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from . import auth, users, tiers, memberships, payments, email, email_templates +from . import auth, users, tiers, memberships, payments, email, email_templates, events api_router = APIRouter() @@ -10,3 +10,4 @@ api_router.include_router(memberships.router, prefix="/memberships", tags=["memb api_router.include_router(payments.router, prefix="/payments", tags=["payments"]) api_router.include_router(email.router, prefix="/email", tags=["email"]) api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"]) +api_router.include_router(events.router, prefix="/events", tags=["events"]) diff --git a/backend/app/api/v1/events.py b/backend/app/api/v1/events.py new file mode 100644 index 0000000..c0d07d8 --- /dev/null +++ b/backend/app/api/v1/events.py @@ -0,0 +1,207 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import List +from datetime import datetime + +from ...core.database import get_db +from ...models.models import Event, EventRSVP, User, EventStatus +from ...schemas import ( + EventCreate, EventUpdate, EventResponse, EventRSVPResponse, EventRSVPUpdate, MessageResponse +) +from ...api.dependencies import get_current_active_user, get_admin_user + +router = APIRouter() + + +@router.get("/", response_model=List[EventResponse]) +async def get_events( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Get all events (admin) or published events (members)""" + if current_user.role in ['admin', 'super_admin']: + events = db.query(Event).order_by(Event.event_date).all() + else: + events = db.query(Event).filter( + Event.status == EventStatus.PUBLISHED + ).order_by(Event.event_date).all() + return events + + +@router.get("/upcoming", response_model=List[EventResponse]) +async def get_upcoming_events( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Get upcoming events""" + now = datetime.now() + events = db.query(Event).filter( + Event.event_date >= now.date(), + Event.status == EventStatus.PUBLISHED + ).order_by(Event.event_date).all() + return events + + +@router.post("/", response_model=EventResponse, status_code=status.HTTP_201_CREATED) +async def create_event( + event_data: EventCreate, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + """Create a new event (admin only)""" + # Validate event date is in the future + if event_data.event_date < datetime.now(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Event date must be in the future" + ) + + event = Event( + title=event_data.title, + description=event_data.description, + event_date=event_data.event_date, + event_time=event_data.event_time, + location=event_data.location, + max_attendees=event_data.max_attendees, + status=EventStatus.DRAFT, + created_by=current_user.id + ) + + db.add(event) + db.commit() + db.refresh(event) + return event + + +@router.put("/{event_id}", response_model=EventResponse) +async def update_event( + event_id: int, + event_data: EventUpdate, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + """Update an event (admin only)""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Event not found" + ) + + # Update fields + for field, value in event_data.dict(exclude_unset=True).items(): + setattr(event, field, value) + + event.updated_at = datetime.now() + db.commit() + db.refresh(event) + return event + + +@router.delete("/{event_id}", response_model=MessageResponse) +async def delete_event( + event_id: int, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + """Delete an event (admin only)""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Event not found" + ) + + db.delete(event) + db.commit() + return {"message": "Event deleted successfully"} + + +@router.get("/{event_id}/rsvps", response_model=List[EventRSVPResponse]) +async def get_event_rsvps( + event_id: int, + current_user: User = Depends(get_admin_user), + db: Session = Depends(get_db) +): + """Get RSVPs for an event (admin only)""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Event not found" + ) + + rsvps = db.query(EventRSVP).filter(EventRSVP.event_id == event_id).all() + return rsvps + + +@router.post("/{event_id}/rsvp", response_model=EventRSVPResponse) +async def create_or_update_rsvp( + event_id: int, + rsvp_data: EventRSVPUpdate, + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Create or update RSVP for an event""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Event not found" + ) + + if event.status != EventStatus.PUBLISHED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Event is not available for RSVP" + ) + + # Check if RSVP already exists + existing_rsvp = db.query(EventRSVP).filter( + EventRSVP.event_id == event_id, + EventRSVP.user_id == current_user.id + ).first() + + if existing_rsvp: + # Update existing RSVP + existing_rsvp.status = rsvp_data.status + if rsvp_data.notes is not None: + existing_rsvp.notes = rsvp_data.notes + existing_rsvp.updated_at = datetime.now() + db.commit() + db.refresh(existing_rsvp) + return existing_rsvp + else: + # Check attendee limit + if event.max_attendees: + current_rsvp_count = db.query(EventRSVP).filter( + EventRSVP.event_id == event_id, + EventRSVP.status == 'attending' + ).count() + if current_rsvp_count >= event.max_attendees: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Event is at maximum capacity" + ) + + # Create new RSVP + rsvp = EventRSVP( + event_id=event_id, + user_id=current_user.id, + status=rsvp_data.status, + notes=rsvp_data.notes + ) + db.add(rsvp) + db.commit() + db.refresh(rsvp) + return rsvp + + +@router.get("/my-rsvps", response_model=List[EventRSVPResponse]) +async def get_my_rsvps( + current_user: User = Depends(get_current_active_user), + db: Session = Depends(get_db) +): + """Get current user's RSVPs""" + rsvps = db.query(EventRSVP).filter(EventRSVP.user_id == current_user.id).all() + return rsvps \ No newline at end of file diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2bad475..c57c722 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -41,7 +41,7 @@ class Settings(BaseSettings): FRONTEND_URL: str = "http://localhost:3500" # CORS - BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"] + BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080", "https://members.sasalliance.org"] # File Storage UPLOAD_DIR: str = "/app/uploads" diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 2ae64f2..d5160ed 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -135,6 +135,7 @@ class Event(Base): title = Column(String(255), nullable=False) description = Column(Text, nullable=True) event_date = Column(DateTime, nullable=False) + event_time = Column(String(10), nullable=True) # HH:MM format location = Column(String(255), nullable=True) max_attendees = Column(Integer, nullable=True) status = Column(SQLEnum(EventStatus, values_callable=lambda x: [e.value for e in x]), default=EventStatus.DRAFT, nullable=False) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index b508389..97b5489 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -30,6 +30,13 @@ from .schemas import ( EmailTemplateCreate, EmailTemplateUpdate, EmailTemplateResponse, + EventBase, + EventCreate, + EventUpdate, + EventResponse, + EventRSVPBase, + EventRSVPUpdate, + EventRSVPResponse, ) __all__ = [ @@ -64,4 +71,11 @@ __all__ = [ "EmailTemplateCreate", "EmailTemplateUpdate", "EmailTemplateResponse", + "EventBase", + "EventCreate", + "EventUpdate", + "EventResponse", + "EventRSVPBase", + "EventRSVPUpdate", + "EventRSVPResponse", ] diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index b468059..983ea24 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -229,3 +229,58 @@ class EmailTemplateResponse(EmailTemplateBase): is_active: bool created_at: datetime updated_at: datetime + + +# Event Schemas +class EventBase(BaseModel): + title: str = Field(..., min_length=1, max_length=255) + description: Optional[str] = None + event_date: datetime + event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$') + location: Optional[str] = None + max_attendees: Optional[int] = Field(None, gt=0) + + +class EventCreate(EventBase): + pass + + +class EventUpdate(BaseModel): + title: Optional[str] = Field(None, min_length=1, max_length=255) + description: Optional[str] = None + event_date: Optional[datetime] = None + event_time: Optional[str] = Field(None, pattern=r'^([01]?[0-9]|2[0-3]):[0-5][0-9]$') + location: Optional[str] = None + max_attendees: Optional[int] = Field(None, gt=0) + status: Optional[str] = None + + +class EventResponse(EventBase): + model_config = ConfigDict(from_attributes=True) + + id: int + status: str + created_by: int + created_at: datetime + updated_at: datetime + + +# Event RSVP Schemas +class EventRSVPBase(BaseModel): + status: str = Field(..., pattern="^(pending|attending|not_attending|maybe)$") + notes: Optional[str] = None + + +class EventRSVPUpdate(EventRSVPBase): + pass + + +class EventRSVPResponse(EventRSVPBase): + model_config = ConfigDict(from_attributes=True) + + id: int + event_id: int + user_id: int + attended: bool + created_at: datetime + updated_at: datetime diff --git a/database/init.sql b/database/init.sql index 8ccdd2b..b1ff6d2 100644 --- a/database/init.sql +++ b/database/init.sql @@ -71,6 +71,7 @@ CREATE TABLE events ( title VARCHAR(255) NOT NULL, description TEXT, event_date TIMESTAMP NOT NULL, + event_time VARCHAR(10), -- HH:MM format location VARCHAR(255), max_attendees INT, status ENUM('draft', 'published', 'cancelled', 'completed') NOT NULL DEFAULT 'draft', diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index faddda8..f4b28fb 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { authService, userService, membershipService, paymentService, User, Membership, Payment } from '../services/membershipService'; +import { authService, userService, membershipService, paymentService, eventService, User, Membership, Payment, Event, EventRSVP } from '../services/membershipService'; import MembershipSetup from '../components/MembershipSetup'; import ProfileMenu from '../components/ProfileMenu'; import ProfileEdit from '../components/ProfileEdit'; @@ -21,6 +21,11 @@ const Dashboard: React.FC = () => { const [showUserDetails, setShowUserDetails] = useState(false); const [isEditingUser, setIsEditingUser] = useState(false); const [editFormData, setEditFormData] = useState>({}); + const [upcomingEvents, setUpcomingEvents] = useState([]); + const [allEvents, setAllEvents] = useState([]); + const [eventRSVPs, setEventRSVPs] = useState([]); + const [eventRSVPCounts, setEventRSVPCounts] = useState<{[eventId: number]: {attending: number, maybe: number, not_attending: number}}>({}); + const [rsvpLoading, setRsvpLoading] = useState<{[eventId: number]: boolean}>({}); useEffect(() => { if (!authService.isAuthenticated()) { @@ -31,6 +36,16 @@ const Dashboard: React.FC = () => { loadData(); }, []); + const mergeRSVPStatus = (events: Event[], rsvps: EventRSVP[]): Event[] => { + return events.map(event => { + const rsvp = rsvps.find(r => r.event_id === event.id); + return { + ...event, + rsvp_status: rsvp ? rsvp.status : undefined + }; + }); + }; + const loadData = async () => { try { const [userData, membershipData, paymentData] = await Promise.all([ @@ -43,16 +58,31 @@ const Dashboard: React.FC = () => { setMemberships(membershipData); setPayments(paymentData); + // Load upcoming events and user's RSVPs + const [eventsData, rsvpsData] = await Promise.all([ + eventService.getUpcomingEvents(), + eventService.getMyRSVPs() + ]); + + // Merge RSVP status with events + const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData); + setUpcomingEvents(eventsWithRSVP); + // Load admin data if user is admin if (userData.role === 'admin' || userData.role === 'super_admin') { - const [allPaymentsData, allMembershipsData, allUsersData] = await Promise.all([ + const [allPaymentsData, allMembershipsData, allUsersData, allEventsData] = await Promise.all([ paymentService.getAllPayments(), membershipService.getAllMemberships(), - userService.getAllUsers() + userService.getAllUsers(), + eventService.getAllEvents() ]); setAllPayments(allPaymentsData); setAllMemberships(allMembershipsData); setAllUsers(allUsersData); + setAllEvents(allEventsData); + + // Load RSVP counts for all events + await loadEventRSVPCounts(allEventsData); } } catch (error) { console.error('Failed to load data:', error); @@ -61,6 +91,26 @@ const Dashboard: React.FC = () => { } }; + const loadEventRSVPCounts = async (events: Event[]) => { + const counts: {[eventId: number]: {attending: number, maybe: number, not_attending: number}} = {}; + + for (const event of events) { + try { + const rsvps = await eventService.getEventRSVPs(event.id); + counts[event.id] = { + attending: rsvps.filter(r => r.status === 'attending').length, + maybe: rsvps.filter(r => r.status === 'maybe').length, + not_attending: rsvps.filter(r => r.status === 'not_attending').length + }; + } catch (error) { + console.error(`Failed to load RSVPs for event ${event.id}:`, error); + counts[event.id] = { attending: 0, maybe: 0, not_attending: 0 }; + } + } + + setEventRSVPCounts(counts); + }; + const handleMembershipSetup = () => { setShowMembershipSetup(true); }; @@ -198,11 +248,73 @@ const Dashboard: React.FC = () => { } }; - const handleFormChange = (field: keyof User, value: string) => { - setEditFormData(prev => ({ - ...prev, - [field]: value - })); + const handleRSVP = async (eventId: number, status: 'attending' | 'not_attending' | 'maybe') => { + // Set loading state for this event + setRsvpLoading(prev => ({ ...prev, [eventId]: true })); + + // Optimistically update the UI + setUpcomingEvents(prevEvents => + prevEvents.map(event => + event.id === eventId + ? { ...event, rsvp_status: status } + : event + ) + ); + + try { + await eventService.createOrUpdateRSVP(eventId, { status }); + // Reload RSVPs and merge with events to get the latest data + const [eventsData, rsvpsData] = await Promise.all([ + eventService.getUpcomingEvents(), + eventService.getMyRSVPs() + ]); + const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData); + setUpcomingEvents(eventsWithRSVP); + } catch (error) { + console.error('Failed to update RSVP:', error); + alert('Failed to update RSVP. Please try again.'); + // Revert optimistic update on error + const [eventsData, rsvpsData] = await Promise.all([ + eventService.getUpcomingEvents(), + eventService.getMyRSVPs() + ]); + const eventsWithRSVP = mergeRSVPStatus(eventsData, rsvpsData); + setUpcomingEvents(eventsWithRSVP); + } finally { + // Clear loading state + setRsvpLoading(prev => ({ ...prev, [eventId]: false })); + } + }; + + const handlePublishEvent = async (eventId: number) => { + try { + await eventService.updateEvent(eventId, { status: 'published' }); + // Reload events to reflect the change + const eventsData = await eventService.getAllEvents(); + setAllEvents(eventsData); + // Reload RSVP counts + await loadEventRSVPCounts(eventsData); + } catch (error) { + console.error('Failed to publish event:', error); + alert('Failed to publish event. Please try again.'); + } + }; + + const handleCancelEvent = async (eventId: number) => { + if (!confirm('Are you sure you want to cancel this event?')) { + return; + } + try { + await eventService.updateEvent(eventId, { status: 'cancelled' }); + // Reload events to reflect the change + const eventsData = await eventService.getAllEvents(); + setAllEvents(eventsData); + // Reload RSVP counts + await loadEventRSVPCounts(eventsData); + } catch (error) { + console.error('Failed to cancel event:', error); + alert('Failed to cancel event. Please try again.'); + } }; const formatDate = (dateString: string) => { @@ -271,10 +383,11 @@ const Dashboard: React.FC = () => {

Your Membership

{activeMembership.tier.name}

+

Membership Number: {activeMembership.id}

Status: {activeMembership.status.toUpperCase()}

Annual Fee: £{activeMembership.tier.annual_fee.toFixed(2)}

-

Member since: {formatDate(activeMembership.start_date)}

-

Renewal Date: {formatDate(activeMembership.end_date)}

+

Valid From: {formatDate(activeMembership.start_date)}

+

Valid Until: {formatDate(activeMembership.end_date)}

Auto Renew: {activeMembership.auto_renew ? 'Yes' : 'No'}

Benefits: @@ -332,6 +445,125 @@ const Dashboard: React.FC = () => { )}
+ {/* Upcoming Events */} +
+

Upcoming Events

+ {upcomingEvents.length > 0 ? ( +
+ {upcomingEvents.map(event => ( +
+
+
+

{event.title}

+

+ {formatDate(event.event_date)} at {event.event_time} +

+ {event.location && ( +

+ 📍 {event.location} +

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

+ {event.description} +

+ )} + {event.rsvp_status && ( +
+ Your RSVP: {event.rsvp_status.replace('_', ' ')} +
+ )} +
+ ))} +
+ ) : ( +

No upcoming events at this time.

+ )} +
+ {/* Admin Section */} {(user?.role === 'admin' || user?.role === 'super_admin') && (
@@ -449,6 +681,7 @@ const Dashboard: React.FC = () => { Name Email + Membership # Role Status Joined @@ -456,64 +689,68 @@ const Dashboard: React.FC = () => { - {filteredUsers.map(u => ( - handleUserClick(u)} - > - {u.first_name} {u.last_name} - {u.email} - - - {u.role.toUpperCase()} - - - - - {u.is_active ? 'ACTIVE' : 'INACTIVE'} - - - {formatDate(u.created_at)} - - {u.role === 'member' && ( - - )} - {u.role === 'admin' && u.id !== user?.id && ( - - )} - {u.role === 'super_admin' && ( - Super Admin - )} - - - ))} + {filteredUsers.map(u => { + const userMembership = allMemberships.find(m => m.user_id === u.id && m.status === 'active'); + return ( + handleUserClick(u)} + > + {u.first_name} {u.last_name} + {u.email} + {userMembership ? userMembership.id : 'N/A'} + + + {u.role.toUpperCase()} + + + + + {u.is_active ? 'ACTIVE' : 'INACTIVE'} + + + {formatDate(u.created_at)} + + {u.role === 'member' && ( + + )} + {u.role === 'admin' && u.id !== user?.id && ( + + )} + {u.role === 'super_admin' && ( + Super Admin + )} + + + ); + })}
@@ -529,6 +766,110 @@ const Dashboard: React.FC = () => { /> )} + {/* Event Management Section for Admins */} + {(user?.role === 'admin' || user?.role === 'super_admin') && ( +
+

Event Management

+ + {/* Create New Event Button */} +
+ +
+ + {/* Events List */} +
+ + + + + + + + + + + + + {allEvents.map(event => ( + + + + + + + + + ))} + +
EventDate & TimeLocationStatusRSVPsActions
+
+ {event.title} + {event.description && ( +
+ {event.description} +
+ )} +
+
+
{formatDate(event.event_date)}
+
{event.event_time}
+
{event.location || 'TBD'} + + {event.status.toUpperCase()} + + + {eventRSVPCounts[event.id] ? ( +
+
Attending: {eventRSVPCounts[event.id].attending}
+
Maybe: {eventRSVPCounts[event.id].maybe}
+
Not: {eventRSVPCounts[event.id].not_attending}
+
+ ) : ( + Loading... + )} +
+
+ + {event.status === 'draft' && ( + + )} + {event.status === 'published' && ( + + )} +
+
+
+ + {allEvents.length === 0 && ( +

No events created yet.

+ )} +
+ )} + {/* User Details Modal */} {showUserDetails && selectedUser && (
{ + const response = await api.get('/events/'); + return response.data; + }, + + async getUpcomingEvents(): Promise { + const response = await api.get('/events/upcoming'); + return response.data; + }, + + async createEvent(data: EventCreateData): Promise { + const response = await api.post('/events/', data); + return response.data; + }, + + async updateEvent(eventId: number, data: EventUpdateData): Promise { + const response = await api.put(`/events/${eventId}`, data); + return response.data; + }, + + async deleteEvent(eventId: number): Promise<{ message: string }> { + const response = await api.delete(`/events/${eventId}`); + return response.data; + }, + + async getEventRSVPs(eventId: number): Promise { + const response = await api.get(`/events/${eventId}/rsvps`); + return response.data; + }, + + async createOrUpdateRSVP(eventId: number, data: EventRSVPData): Promise { + const response = await api.post(`/events/${eventId}/rsvp`, data); + return response.data; + }, + + async getMyRSVPs(): Promise { + const response = await api.get('/events/my-rsvps'); + return response.data; + } +};