feat: add QR code event check-in system
Backend: - Add event_checkin_tokens table with unique tokens per event - Implement GET /api/events/:slug/details endpoint (on-demand token generation) - Implement POST /api/events/checkin/:token endpoint (date validation only in production) - Implement DELETE /api/events/:slug/leave endpoint - Add comprehensive test suite for check-in endpoints Frontend: - Add EventDetailsPage with QR code display, participant list, and stats - Add EventCheckinPage with success/error screens - Add "Leave Event" button with confirmation modal to EventChatPage - Install qrcode.react library for QR code generation - Update routing and API client with new endpoints Features: - QR codes valid from (startDate-1d) to (endDate+1d) - Development mode bypasses date validation for testing - Automatic participant count tracking - Duplicate check-in prevention - Token reuse for same event (generated once, cached)
This commit is contained in:
263
backend/src/__tests__/events-checkin.test.js
Normal file
263
backend/src/__tests__/events-checkin.test.js
Normal file
@@ -0,0 +1,263 @@
|
||||
const request = require('supertest');
|
||||
const app = require('../app');
|
||||
const { prisma } = require('../utils/db');
|
||||
const { generateToken } = require('../utils/auth');
|
||||
|
||||
let testUser;
|
||||
let testEvent;
|
||||
let authToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Clean up database
|
||||
await prisma.eventParticipant.deleteMany({});
|
||||
await prisma.eventCheckinToken.deleteMany({});
|
||||
await prisma.chatRoom.deleteMany({});
|
||||
await prisma.event.deleteMany({});
|
||||
await prisma.user.deleteMany({});
|
||||
|
||||
// Create test user
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
username: 'checkintest',
|
||||
email: 'checkin@test.com',
|
||||
passwordHash: 'hashedpassword',
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Generate auth token
|
||||
authToken = generateToken(testUser.id);
|
||||
|
||||
// Create test event
|
||||
testEvent = await prisma.event.create({
|
||||
data: {
|
||||
slug: 'test-event-slug',
|
||||
name: 'Test Event',
|
||||
location: 'Test Location',
|
||||
startDate: new Date('2025-06-01'),
|
||||
endDate: new Date('2025-06-03'),
|
||||
description: 'Test event description',
|
||||
participantsCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Create chat room for event
|
||||
await prisma.chatRoom.create({
|
||||
data: {
|
||||
eventId: testEvent.id,
|
||||
type: 'event',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up
|
||||
await prisma.eventParticipant.deleteMany({});
|
||||
await prisma.eventCheckinToken.deleteMany({});
|
||||
await prisma.chatRoom.deleteMany({});
|
||||
await prisma.event.deleteMany({});
|
||||
await prisma.user.deleteMany({});
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe('Event Check-in API Tests', () => {
|
||||
describe('GET /api/events/:slug/details', () => {
|
||||
it('should get event details with generated check-in token', async () => {
|
||||
const response = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/details`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body.data).toHaveProperty('event');
|
||||
expect(response.body.data).toHaveProperty('checkin');
|
||||
expect(response.body.data).toHaveProperty('participants');
|
||||
expect(response.body.data).toHaveProperty('stats');
|
||||
|
||||
// Event data
|
||||
expect(response.body.data.event).toHaveProperty('slug', testEvent.slug);
|
||||
expect(response.body.data.event).toHaveProperty('name', testEvent.name);
|
||||
|
||||
// Check-in token
|
||||
expect(response.body.data.checkin).toHaveProperty('token');
|
||||
expect(response.body.data.checkin).toHaveProperty('url');
|
||||
expect(response.body.data.checkin).toHaveProperty('validFrom');
|
||||
expect(response.body.data.checkin).toHaveProperty('validUntil');
|
||||
expect(response.body.data.checkin.url).toContain(response.body.data.checkin.token);
|
||||
|
||||
// Stats
|
||||
expect(response.body.data.stats).toHaveProperty('totalParticipants', 0);
|
||||
expect(response.body.data.participants).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent event', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/events/non-existent-slug/details')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error', 'Event not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/details`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/events/checkin/:token', () => {
|
||||
let checkinToken;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Get check-in token
|
||||
const detailsResponse = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/details`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
checkinToken = detailsResponse.body.data.checkin.token;
|
||||
});
|
||||
|
||||
it('should check-in user with valid token', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/events/checkin/${checkinToken}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body).toHaveProperty('alreadyCheckedIn', false);
|
||||
expect(response.body.data).toHaveProperty('event');
|
||||
expect(response.body.data).toHaveProperty('joinedAt');
|
||||
expect(response.body.data.event).toHaveProperty('slug', testEvent.slug);
|
||||
});
|
||||
|
||||
it('should detect already checked-in user', async () => {
|
||||
const response = await request(app)
|
||||
.post(`/api/events/checkin/${checkinToken}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body).toHaveProperty('alreadyCheckedIn', true);
|
||||
});
|
||||
|
||||
it('should return 404 for invalid token', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/events/checkin/invalid-token-123')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error', 'Invalid check-in token');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
await request(app)
|
||||
.post(`/api/events/checkin/${checkinToken}`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should update participants count', async () => {
|
||||
const detailsResponse = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/details`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(detailsResponse.body.data.stats.totalParticipants).toBeGreaterThan(0);
|
||||
expect(detailsResponse.body.data.participants.length).toBeGreaterThan(0);
|
||||
expect(detailsResponse.body.data.participants[0]).toHaveProperty('userId', testUser.id);
|
||||
expect(detailsResponse.body.data.participants[0]).toHaveProperty('username', testUser.username);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/events/:slug/leave', () => {
|
||||
it('should allow user to leave event', async () => {
|
||||
const response = await request(app)
|
||||
.delete(`/api/events/${testEvent.slug}/leave`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toHaveProperty('success', true);
|
||||
expect(response.body).toHaveProperty('message', 'Successfully left the event');
|
||||
});
|
||||
|
||||
it('should return error if user is not participant', async () => {
|
||||
const response = await request(app)
|
||||
.delete(`/api/events/${testEvent.slug}/leave`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error', 'You are not a participant of this event');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent event', async () => {
|
||||
const response = await request(app)
|
||||
.delete('/api/events/non-existent-slug/leave')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect('Content-Type', /json/)
|
||||
.expect(404);
|
||||
|
||||
expect(response.body).toHaveProperty('success', false);
|
||||
expect(response.body).toHaveProperty('error', 'Event not found');
|
||||
});
|
||||
|
||||
it('should require authentication', async () => {
|
||||
await request(app)
|
||||
.delete(`/api/events/${testEvent.slug}/leave`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('should update participants count after leaving', async () => {
|
||||
// First check-in
|
||||
const detailsResponse1 = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/details`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
const checkinToken = detailsResponse1.body.data.checkin.token;
|
||||
|
||||
await request(app)
|
||||
.post(`/api/events/checkin/${checkinToken}`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
// Then leave
|
||||
await request(app)
|
||||
.delete(`/api/events/${testEvent.slug}/leave`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
// Check participants count
|
||||
const detailsResponse2 = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/details`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
expect(detailsResponse2.body.data.stats.totalParticipants).toBe(0);
|
||||
expect(detailsResponse2.body.data.participants).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Check-in token generation', () => {
|
||||
it('should reuse existing token for same event', async () => {
|
||||
const response1 = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/details`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
const token1 = response1.body.data.checkin.token;
|
||||
|
||||
const response2 = await request(app)
|
||||
.get(`/api/events/${testEvent.slug}/details`)
|
||||
.set('Authorization', `Bearer ${authToken}`);
|
||||
|
||||
const token2 = response2.body.data.checkin.token;
|
||||
|
||||
expect(token1).toBe(token2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -179,4 +179,269 @@ router.get('/:slug/messages', authenticate, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/events/checkin/:token - Check-in to event using QR code token
|
||||
router.post('/checkin/:token', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Find check-in token
|
||||
const checkinToken = await prisma.eventCheckinToken.findUnique({
|
||||
where: { token },
|
||||
include: {
|
||||
event: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!checkinToken) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Invalid check-in token',
|
||||
});
|
||||
}
|
||||
|
||||
const event = checkinToken.event;
|
||||
|
||||
// Validate dates (only in production)
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
if (isProduction) {
|
||||
const now = new Date();
|
||||
const validFrom = new Date(event.startDate);
|
||||
validFrom.setDate(validFrom.getDate() - 1);
|
||||
const validUntil = new Date(event.endDate);
|
||||
validUntil.setDate(validUntil.getDate() + 1);
|
||||
|
||||
if (now < validFrom || now > validUntil) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Check-in is not available for this event at this time',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is already participating
|
||||
const existingParticipant = await prisma.eventParticipant.findUnique({
|
||||
where: {
|
||||
userId_eventId: {
|
||||
userId,
|
||||
eventId: event.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingParticipant) {
|
||||
// User already checked in - return event info
|
||||
return res.json({
|
||||
success: true,
|
||||
alreadyCheckedIn: true,
|
||||
data: {
|
||||
event: {
|
||||
id: event.id,
|
||||
slug: event.slug,
|
||||
name: event.name,
|
||||
location: event.location,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
},
|
||||
joinedAt: existingParticipant.joinedAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add user to event participants
|
||||
const participant = await prisma.eventParticipant.create({
|
||||
data: {
|
||||
userId,
|
||||
eventId: event.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Update participants count
|
||||
await prisma.event.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
participantsCount: {
|
||||
increment: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
alreadyCheckedIn: false,
|
||||
data: {
|
||||
event: {
|
||||
id: event.id,
|
||||
slug: event.slug,
|
||||
name: event.name,
|
||||
location: event.location,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
},
|
||||
joinedAt: participant.joinedAt,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/events/:slug/details - Get event details with check-in token and participants
|
||||
router.get('/:slug/details', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
// Find event by slug with participants
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
participants: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
avatar: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
joinedAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Event not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Find or create check-in token (on-demand generation)
|
||||
let checkinToken = await prisma.eventCheckinToken.findUnique({
|
||||
where: { eventId: event.id },
|
||||
});
|
||||
|
||||
if (!checkinToken) {
|
||||
checkinToken = await prisma.eventCheckinToken.create({
|
||||
data: {
|
||||
eventId: event.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate valid dates (startDate - 1 day to endDate + 1 day)
|
||||
const validFrom = new Date(event.startDate);
|
||||
validFrom.setDate(validFrom.getDate() - 1);
|
||||
const validUntil = new Date(event.endDate);
|
||||
validUntil.setDate(validUntil.getDate() + 1);
|
||||
|
||||
// Build check-in URL
|
||||
const baseUrl = process.env.FRONTEND_URL || 'http://localhost:8080';
|
||||
const checkinUrl = `${baseUrl}/events/checkin/${checkinToken.token}`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
event: {
|
||||
id: event.id,
|
||||
slug: event.slug,
|
||||
name: event.name,
|
||||
location: event.location,
|
||||
startDate: event.startDate,
|
||||
endDate: event.endDate,
|
||||
description: event.description,
|
||||
},
|
||||
checkin: {
|
||||
token: checkinToken.token,
|
||||
url: checkinUrl,
|
||||
validFrom,
|
||||
validUntil,
|
||||
},
|
||||
participants: event.participants.map(p => ({
|
||||
userId: p.user.id,
|
||||
username: p.user.username,
|
||||
avatar: p.user.avatar,
|
||||
firstName: p.user.firstName,
|
||||
lastName: p.user.lastName,
|
||||
joinedAt: p.joinedAt,
|
||||
})),
|
||||
stats: {
|
||||
totalParticipants: event.participants.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/events/:slug/leave - Leave an event (remove from participants)
|
||||
router.delete('/:slug/leave', authenticate, async (req, res, next) => {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Find event by slug
|
||||
const event = await prisma.event.findUnique({
|
||||
where: { slug },
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Event not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is participating
|
||||
const participant = await prisma.eventParticipant.findUnique({
|
||||
where: {
|
||||
userId_eventId: {
|
||||
userId,
|
||||
eventId: event.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'You are not a participant of this event',
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from participants
|
||||
await prisma.eventParticipant.delete({
|
||||
where: {
|
||||
userId_eventId: {
|
||||
userId,
|
||||
eventId: event.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update participants count
|
||||
await prisma.event.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
participantsCount: {
|
||||
decrement: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Successfully left the event',
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Reference in New Issue
Block a user