feat: track event participation and show joined events first
Backend: - Add EventParticipant model to track user-event participation - Create database migration for event_participants table - Record participation when user joins event chat via Socket.IO - Update GET /api/events to include isJoined flag for current user - Sort events: joined events first, then by start date - Add authenticate middleware to GET /api/events Frontend: - Replace mock events with real API data from backend - Add loading and error states to EventsPage - Display "Joined" badge on events user has joined - Highlight joined events with colored border - Show "Open chat" vs "Join chat" button text - Auto-refresh events list when navigating back When users join an event chat, this is now recorded in the database. Joined events appear at the top of the list with visual indicators.
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "event_participants" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"user_id" INTEGER NOT NULL,
|
||||||
|
"event_id" INTEGER NOT NULL,
|
||||||
|
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "event_participants_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "event_participants_user_id_idx" ON "event_participants"("user_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "event_participants_event_id_idx" ON "event_participants"("event_id");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "event_participants_user_id_event_id_key" ON "event_participants"("user_id", "event_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "event_participants" ADD CONSTRAINT "event_participants_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "event_participants" ADD CONSTRAINT "event_participants_event_id_fkey" FOREIGN KEY ("event_id") REFERENCES "events"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -53,6 +53,7 @@ model User {
|
|||||||
matchesAsUser2 Match[] @relation("MatchUser2")
|
matchesAsUser2 Match[] @relation("MatchUser2")
|
||||||
ratingsGiven Rating[] @relation("RaterRatings")
|
ratingsGiven Rating[] @relation("RaterRatings")
|
||||||
ratingsReceived Rating[] @relation("RatedRatings")
|
ratingsReceived Rating[] @relation("RatedRatings")
|
||||||
|
eventParticipants EventParticipant[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -72,6 +73,7 @@ model Event {
|
|||||||
// Relations
|
// Relations
|
||||||
chatRooms ChatRoom[]
|
chatRooms ChatRoom[]
|
||||||
matches Match[]
|
matches Match[]
|
||||||
|
participants EventParticipant[]
|
||||||
|
|
||||||
@@map("events")
|
@@map("events")
|
||||||
}
|
}
|
||||||
@@ -153,3 +155,20 @@ model Rating {
|
|||||||
@@index([ratedId])
|
@@index([ratedId])
|
||||||
@@map("ratings")
|
@@map("ratings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event participants (tracks which users joined which events)
|
||||||
|
model EventParticipant {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId Int @map("user_id")
|
||||||
|
eventId Int @map("event_id")
|
||||||
|
joinedAt DateTime @default(now()) @map("joined_at")
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, eventId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([eventId])
|
||||||
|
@@map("event_participants")
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ const { authenticate } = require('../middleware/auth');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// GET /api/events - List all events
|
// GET /api/events - List all events
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', authenticate, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const userId = req.user.id;
|
||||||
|
|
||||||
|
// Fetch all events with participation info
|
||||||
const events = await prisma.event.findMany({
|
const events = await prisma.event.findMany({
|
||||||
orderBy: {
|
|
||||||
startDate: 'asc',
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -21,13 +21,46 @@ router.get('/', async (req, res, next) => {
|
|||||||
participantsCount: true,
|
participantsCount: true,
|
||||||
description: true,
|
description: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
|
participants: {
|
||||||
|
where: {
|
||||||
|
userId: userId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
joinedAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Transform data and add isJoined flag
|
||||||
|
const eventsWithJoinedStatus = events.map(event => ({
|
||||||
|
id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
location: event.location,
|
||||||
|
startDate: event.startDate,
|
||||||
|
endDate: event.endDate,
|
||||||
|
worldsdcId: event.worldsdcId,
|
||||||
|
participantsCount: event.participantsCount,
|
||||||
|
description: event.description,
|
||||||
|
createdAt: event.createdAt,
|
||||||
|
isJoined: event.participants.length > 0,
|
||||||
|
joinedAt: event.participants[0]?.joinedAt || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Sort: joined events first, then by start date
|
||||||
|
eventsWithJoinedStatus.sort((a, b) => {
|
||||||
|
// First, sort by joined status (joined events first)
|
||||||
|
if (a.isJoined && !b.isJoined) return -1;
|
||||||
|
if (!a.isJoined && b.isJoined) return 1;
|
||||||
|
|
||||||
|
// Then sort by start date
|
||||||
|
return new Date(a.startDate) - new Date(b.startDate);
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
count: events.length,
|
count: eventsWithJoinedStatus.length,
|
||||||
data: events,
|
data: eventsWithJoinedStatus,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -61,6 +61,21 @@ function initializeSocket(httpServer) {
|
|||||||
socket.currentEventRoom = roomName;
|
socket.currentEventRoom = roomName;
|
||||||
socket.currentEventId = eventId;
|
socket.currentEventId = eventId;
|
||||||
|
|
||||||
|
// Record event participation in database
|
||||||
|
await prisma.eventParticipant.upsert({
|
||||||
|
where: {
|
||||||
|
userId_eventId: {
|
||||||
|
userId: socket.user.id,
|
||||||
|
eventId: parseInt(eventId),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {}, // Don't update anything if already exists
|
||||||
|
create: {
|
||||||
|
userId: socket.user.id,
|
||||||
|
eventId: parseInt(eventId),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Add user to active users
|
// Add user to active users
|
||||||
if (!activeUsers.has(eventId)) {
|
if (!activeUsers.has(eventId)) {
|
||||||
activeUsers.set(eventId, new Set());
|
activeUsers.set(eventId, new Set());
|
||||||
|
|||||||
@@ -1,15 +1,61 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Layout from '../components/layout/Layout';
|
import Layout from '../components/layout/Layout';
|
||||||
import { mockEvents } from '../mocks/events';
|
import { eventsAPI } from '../services/api';
|
||||||
import { Calendar, MapPin, Users } from 'lucide-react';
|
import { Calendar, MapPin, Users, Loader2, CheckCircle } from 'lucide-react';
|
||||||
|
|
||||||
const EventsPage = () => {
|
const EventsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [events, setEvents] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchEvents = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await eventsAPI.getAll();
|
||||||
|
setEvents(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load events');
|
||||||
|
console.error('Error loading events:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchEvents();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleJoinEvent = (eventId) => {
|
const handleJoinEvent = (eventId) => {
|
||||||
navigate(`/events/${eventId}/chat`);
|
navigate(`/events/${eventId}/chat`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="max-w-4xl mx-auto flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Loader2 className="w-12 h-12 animate-spin text-primary-600" />
|
||||||
|
<p className="text-gray-600">Loading events...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-md p-4 text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
@@ -17,12 +63,22 @@ const EventsPage = () => {
|
|||||||
<p className="text-gray-600 mb-8">Join an event and start connecting with other dancers</p>
|
<p className="text-gray-600 mb-8">Join an event and start connecting with other dancers</p>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
{mockEvents.map((event) => (
|
{events.map((event) => (
|
||||||
<div
|
<div
|
||||||
key={event.id}
|
key={event.id}
|
||||||
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200"
|
className={`bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border-2 ${
|
||||||
|
event.isJoined ? 'border-primary-500' : 'border-gray-200'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-3">{event.name}</h3>
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900">{event.name}</h3>
|
||||||
|
{event.isJoined && (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-1 bg-primary-100 text-primary-700 text-xs font-medium rounded-full">
|
||||||
|
<CheckCircle className="w-3 h-3" />
|
||||||
|
Joined
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
<div className="flex items-center text-gray-600">
|
<div className="flex items-center text-gray-600">
|
||||||
@@ -32,22 +88,24 @@ const EventsPage = () => {
|
|||||||
<div className="flex items-center text-gray-600">
|
<div className="flex items-center text-gray-600">
|
||||||
<Calendar className="w-4 h-4 mr-2" />
|
<Calendar className="w-4 h-4 mr-2" />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
{new Date(event.start_date).toLocaleDateString('en-US')} - {new Date(event.end_date).toLocaleDateString('en-US')}
|
{new Date(event.startDate).toLocaleDateString('en-US')} - {new Date(event.endDate).toLocaleDateString('en-US')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-gray-600">
|
<div className="flex items-center text-gray-600">
|
||||||
<Users className="w-4 h-4 mr-2" />
|
<Users className="w-4 h-4 mr-2" />
|
||||||
<span className="text-sm">{event.participants_count} participants</span>
|
<span className="text-sm">{event.participantsCount} participants</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-gray-600 text-sm mb-4">{event.description}</p>
|
{event.description && (
|
||||||
|
<p className="text-gray-600 text-sm mb-4">{event.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleJoinEvent(event.id)}
|
onClick={() => handleJoinEvent(event.id)}
|
||||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
className="w-full px-4 py-2 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors"
|
||||||
>
|
>
|
||||||
Join chat
|
{event.isJoined ? 'Open chat' : 'Join chat'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user