diff --git a/QR_CODE_CHECKIN_IMPLEMENTATION.md b/QR_CODE_CHECKIN_IMPLEMENTATION.md new file mode 100644 index 0000000..47652e3 --- /dev/null +++ b/QR_CODE_CHECKIN_IMPLEMENTATION.md @@ -0,0 +1,210 @@ +# QR Code Check-In System Implementation + +## Overview + +Implemented a QR code-based check-in system for event attendees. Event organizers can scan attendee QR codes from their mobile devices to track attendance at cleanup events. + +## Features + +### 1. User QR Code Display +- **Location**: User Profile Page (Overview tab) +- **Component**: `UserQRCode` (`src/components/user/UserQRCode.tsx`) +- Each user has a unique QR code containing their Clerk user ID +- QR code is displayed prominently on their profile for easy access +- Users can show this QR code to event organizers for check-in + +### 2. QR Code Scanner +- **Component**: `QRCodeScanner` (`src/components/event/QRCodeScanner.tsx`) +- Uses `html5-qrcode` library for camera-based scanning +- Mobile-optimized with back camera support +- Real-time scanning with visual feedback +- Error handling for invalid QR codes + +### 3. Event Check-In Page +- **Route**: `/events/:eventId/checkin` +- **Component**: `EventCheckInPage` (`src/pages/EventCheckInPage.tsx`) +- **Access**: Only event organizers can access +- Features: + - Live QR code scanner + - List of registered attendees + - Visual indication of checked-in attendees + - Real-time updates after successful check-in + - Success/error notifications + +### 4. Backend API +- **Endpoint**: `POST /api/events/:id/checkin` +- **Controller**: `eventController.checkInAttendee` +- **Service**: `eventService.checkInAttendee` +- Validates: + - User is the event organizer + - Attendee is registered for the event + - Attendee hasn't already checked in +- Returns attendee information on successful check-in + +## User Flow + +### For Attendees: +1. Navigate to their profile page +2. View their personal QR code in the Overview tab +3. Show QR code to event organizer at the event + +### For Organizers: +1. Navigate to their event details +2. Click "QR Code Check-In" button +3. Grant camera permissions +4. Click "Start Scanning" +5. Point camera at attendee's QR code +6. System automatically checks in the attendee +7. Visual confirmation and attendee list updates + +## Technical Implementation + +### Dependencies Added: +```json +{ + "qrcode.react": "^3.x.x", + "html5-qrcode": "^2.x.x", + "@types/qrcode.react": "^1.x.x" +} +``` + +### QR Code Data Format: +```json +{ + "userId": "clerk_user_id", + "type": "attendee-checkin", + "timestamp": 1234567890 +} +``` + +### Key Components: + +1. **UserQRCode.tsx** + - Generates QR code using `qrcode.react` + - High error correction level (H) + - 200x200px size with margin + - Displays user's name below QR code + +2. **QRCodeScanner.tsx** + - Uses Html5Qrcode for camera access + - 250x250px scanning box + - 10 FPS scanning rate + - Parses and validates QR code data + - Callbacks for successful scan and errors + +3. **EventCheckInPage.tsx** + - Fetches event and attendee data + - Verifies organizer permissions + - Manages scanner state + - Handles check-in API calls + - Updates UI in real-time + +### Backend Changes: + +1. **eventController.ts** + - Added `checkInAttendee` method + - Validates organizer permissions + - Finds or creates user from Clerk ID + - Calls service layer for check-in + +2. **eventService.ts** + - Added `checkInAttendee` method + - Validates registration status + - Prevents duplicate check-ins + - Updates attendance record + +3. **eventRoutes.ts** + - Added `POST /:id/checkin` route + - Protected with `requireAuth` middleware + +## UI/UX Enhancements + +### User Profile: +- QR code card with clear instructions +- Prominent placement in Overview tab +- User identification below QR code + +### Check-In Page: +- Mobile-responsive design +- Large, easy-to-use scanner interface +- Clear start/stop buttons +- Real-time attendee list with check-in status +- Color-coded status indicators (green for checked in) +- Success/error toast notifications + +### Event Detail Modal: +- Added "QR Code Check-In" button for organizers +- Purple button with QR icon +- Available before and after event date +- Navigates to dedicated check-in page + +## Security Considerations + +1. **Authorization**: Only event organizers can access check-in page +2. **Validation**: Backend validates all check-in requests +3. **QR Code Format**: Structured JSON with type validation +4. **Timestamp**: Included in QR code (not currently validated, but available for future use) + +## Mobile Optimization + +- Camera access optimized for mobile devices +- Back camera selected by default +- Responsive layout for small screens +- Touch-friendly buttons and controls +- Large scanning area for easy targeting + +## Future Enhancements + +1. **Offline Support**: Cache attendee list for offline check-in +2. **Bulk Check-In**: Allow manual selection of multiple attendees +3. **Check-In History**: Track check-in timestamps +4. **QR Code Expiry**: Add time-based validation to QR codes +5. **Export**: Generate check-in reports +6. **Notifications**: Send confirmation to attendees upon check-in + +## Testing + +### Manual Testing Checklist: +- [ ] User can view their QR code on profile page +- [ ] QR code scanner requests camera permission +- [ ] Scanner successfully reads QR codes +- [ ] Only organizers can access check-in page +- [ ] Check-in updates attendee status +- [ ] Duplicate check-ins are prevented +- [ ] Error messages display correctly +- [ ] Mobile camera works properly +- [ ] Attendee list updates in real-time + +## Files Modified/Created + +### Frontend: +- `src/components/user/UserQRCode.tsx` (new) +- `src/components/user/index.ts` (new) +- `src/components/event/QRCodeScanner.tsx` (new) +- `src/pages/EventCheckInPage.tsx` (new) +- `src/pages/UserProfilePage.tsx` (modified) +- `src/components/event/EventDetailModal.tsx` (modified) +- `src/App.tsx` (modified) +- `package.json` (modified) + +### Backend: +- `server/src/controllers/eventController.ts` (modified) +- `server/src/services/eventService.ts` (modified) +- `server/src/routes/eventRoutes.ts` (modified) + +## Deployment Notes + +1. Ensure camera permissions are properly configured in production +2. HTTPS required for camera access in browsers +3. Test on various mobile devices and browsers +4. Consider rate limiting for check-in endpoint +5. Monitor for QR code scanning performance issues + +## Support + +For issues or questions: +- Check browser camera permissions +- Ensure HTTPS is enabled +- Verify user is registered for the event +- Check that user is the event organizer +- Review browser console for errors diff --git a/package-lock.json b/package-lock.json index 342ac41..a36c6bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,11 @@ "@tanstack/react-query": "^5.90.9", "date-fns": "^4.1.0", "exifr": "^7.1.3", + "html5-qrcode": "^2.3.8", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", "leaflet.markercluster": "^1.5.3", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", @@ -28,11 +30,11 @@ "@types/leaflet": "^1.9.21", "@types/leaflet.heat": "^0.2.5", "@types/leaflet.markercluster": "^1.5.6", + "@types/qrcode.react": "^1.0.5", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", "autoprefixer": "^10.4.22", - "concurrently": "^9.2.1", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", @@ -3292,6 +3294,16 @@ "@types/leaflet": "^1.9" } }, + "node_modules/@types/qrcode.react": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/qrcode.react/-/qrcode.react-1.0.5.tgz", + "integrity": "sha512-BghPtnlwvrvq8QkGa1H25YnN+5OIgCKFuQruncGWLGJYOzeSKiix/4+B9BtfKF2wf5ja8yfyWYA3OXju995G8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.5.tgz", @@ -3393,16 +3405,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3755,21 +3757,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3823,47 +3810,6 @@ "dev": true, "license": "MIT" }, - "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "4.1.2", - "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", - "tree-kill": "1.2.2", - "yargs": "17.7.2" - }, - "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" - } - }, - "node_modules/concurrently/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -4245,13 +4191,6 @@ "dev": true, "license": "ISC" }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -4949,16 +4888,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -5225,6 +5154,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html5-qrcode": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz", + "integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==", + "license": "Apache-2.0" + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -5483,16 +5418,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6625,6 +6550,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -6895,16 +6829,6 @@ "regjsparser": "bin/parser" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -6994,16 +6918,6 @@ "fsevents": "~2.3.2" } }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -7184,19 +7098,6 @@ "node": ">=8" } }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -7353,21 +7254,6 @@ "node": ">= 0.4" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -7470,19 +7356,6 @@ "node": ">=4" } }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -7647,16 +7520,6 @@ "punycode": "^2.1.0" } }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8553,24 +8416,6 @@ "workbox-core": "7.3.0" } }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8578,16 +8423,6 @@ "dev": true, "license": "ISC" }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8595,35 +8430,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 6670a2d..85c518c 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,11 @@ "@tanstack/react-query": "^5.90.9", "date-fns": "^4.1.0", "exifr": "^7.1.3", + "html5-qrcode": "^2.3.8", "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", "leaflet.markercluster": "^1.5.3", + "qrcode.react": "^4.2.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", @@ -32,6 +34,7 @@ "@types/leaflet": "^1.9.21", "@types/leaflet.heat": "^0.2.5", "@types/leaflet.markercluster": "^1.5.6", + "@types/qrcode.react": "^1.0.5", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@vitejs/plugin-react": "^5.1.0", diff --git a/server/src/controllers/eventController.ts b/server/src/controllers/eventController.ts index aaa20a0..5b06693 100644 --- a/server/src/controllers/eventController.ts +++ b/server/src/controllers/eventController.ts @@ -536,4 +536,81 @@ export const eventController = { }); } }, + + // QR Code Check-in + async checkInAttendee(req: AuthRequest, res: Response) { + try { + const organizerClerkId = req.userId; + if (!organizerClerkId) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }); + } + + const organizerId = await findOrCreateUser(organizerClerkId); + const { id } = req.params; + const { clerkUserId } = req.body; + + if (!clerkUserId) { + return res.status(400).json({ + code: 'VALIDATION_ERROR', + message: 'clerkUserId is required', + timestamp: new Date().toISOString(), + }); + } + + // Verify event exists and user is organizer + const event = await eventService.getEventById(id); + if (!event) { + return res.status(404).json({ + code: 'EVENT_NOT_FOUND', + message: 'Event not found', + timestamp: new Date().toISOString(), + }); + } + + if (event.organizerId !== organizerId) { + return res.status(403).json({ + code: 'FORBIDDEN', + message: 'Only the event organizer can check in attendees', + timestamp: new Date().toISOString(), + }); + } + + // Find or create the attendee user + const attendeeUserId = await findOrCreateUser(clerkUserId); + + // Check in the attendee + const result = await eventService.checkInAttendee(id, attendeeUserId); + + res.json(result); + } catch (error: any) { + console.error('Error checking in attendee:', error); + + if (error.message.includes('not registered')) { + return res.status(404).json({ + code: 'NOT_REGISTERED', + message: error.message, + timestamp: new Date().toISOString(), + }); + } + + if (error.message.includes('already checked in')) { + return res.status(409).json({ + code: 'ALREADY_CHECKED_IN', + message: error.message, + timestamp: new Date().toISOString(), + }); + } + + res.status(500).json({ + code: 'CHECKIN_ERROR', + message: 'Failed to check in attendee', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, }; diff --git a/server/src/routes/eventRoutes.ts b/server/src/routes/eventRoutes.ts index 698e38f..bc74b07 100644 --- a/server/src/routes/eventRoutes.ts +++ b/server/src/routes/eventRoutes.ts @@ -20,5 +20,6 @@ router.get('/:id/registration-status', requireAuth, eventController.getRegistrat router.post('/:id/complete', requireAuth, eventController.completeEvent); router.patch('/:id/attendees/:attendeeId', requireAuth, eventController.updateAttendeeStatus); router.post('/:id/attendees/bulk', requireAuth, eventController.bulkUpdateAttendees); +router.post('/:id/checkin', requireAuth, eventController.checkInAttendee); export default router; diff --git a/server/src/services/eventService.ts b/server/src/services/eventService.ts index 2fc07a2..3069870 100644 --- a/server/src/services/eventService.ts +++ b/server/src/services/eventService.ts @@ -491,4 +491,74 @@ export const eventService = { return updates; }, + + async checkInAttendee(eventId: string, userId: string) { + // Check if user is registered + const registration = await prisma.eventRegistration.findUnique({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + include: { + user: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }, + }, + }); + + if (!registration) { + throw new Error('User is not registered for this event'); + } + + if (registration.attended) { + throw new Error('User is already checked in'); + } + + // Update attendance + const updated = await prisma.eventRegistration.update({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + data: { + attended: true, + }, + include: { + user: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }, + }, + }); + + return { + success: true, + username: updated.user.username, + firstName: updated.user.firstName, + lastName: updated.user.lastName, + attendee: { + id: updated.id, + userId: updated.userId, + eventId: updated.eventId, + registeredAt: updated.registeredAt || new Date(), + attended: updated.attended || false, + user: updated.user, + }, + }; + }, }; diff --git a/src/App.tsx b/src/App.tsx index bd7d5ef..c0e62e4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ const ReportDetailDemo = lazy(() => import('./pages/ReportDetailDemo')); const WhyCleanPage = lazy(() => import('./pages/WhyCleanPage')); const UserProfilePage = lazy(() => import('./pages/UserProfilePage')); const EnvironmentalDataDemo = lazy(() => import('./pages/EnvironmentalDataDemo')); +const EventCheckInPage = lazy(() => import('./pages/EventCheckInPage')); // Services import { apiClient } from './services/api'; @@ -152,6 +153,16 @@ function App() { } /> + + + + + + } + /> {/* Demo routes for testing */} (null); const mapInstanceRef = useRef(null); const [showCompletionForm, setShowCompletionForm] = useState(false); + const navigate = useNavigate(); // Check if event date has passed const hasEventPassed = new Date(event.scheduledDate) < new Date(); @@ -300,23 +302,34 @@ export function EventDetailModal({ {event.status === 'upcoming' && !showCompletionForm && (
{canComplete ? ( -
- - {onCancel && ( +
+
- )} + {onCancel && ( + + )} +
+
) : isOrganizer && !hasEventPassed ? (
@@ -334,6 +347,15 @@ export function EventDetailModal({
+ {onCancel && ( + ) : ( + + )} +
+ + {isScanning && ( +

+ Position the QR code within the frame to scan +

+ )} + + ); +} diff --git a/src/components/user/UserQRCode.tsx b/src/components/user/UserQRCode.tsx new file mode 100644 index 0000000..1294a36 --- /dev/null +++ b/src/components/user/UserQRCode.tsx @@ -0,0 +1,37 @@ +import { QRCodeSVG } from 'qrcode.react'; +import { useUser } from '@clerk/clerk-react'; + +export function UserQRCode() { + const { user } = useUser(); + + if (!user) return null; + + // Generate QR code data with user ID + const qrData = JSON.stringify({ + userId: user.id, + type: 'attendee-checkin', + timestamp: Date.now(), + }); + + return ( +
+

+ Your Check-in QR Code +

+

+ Show this QR code to event organizers to check in at events +

+
+ +
+

+ {user.username || user.firstName || 'User'} +

+
+ ); +} diff --git a/src/components/user/index.ts b/src/components/user/index.ts new file mode 100644 index 0000000..9db8046 --- /dev/null +++ b/src/components/user/index.ts @@ -0,0 +1 @@ +export { UserQRCode } from './UserQRCode'; diff --git a/src/pages/EventCheckInPage.tsx b/src/pages/EventCheckInPage.tsx new file mode 100644 index 0000000..56cc52d --- /dev/null +++ b/src/pages/EventCheckInPage.tsx @@ -0,0 +1,274 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useUser } from '@clerk/clerk-react'; +import { QRCodeScanner } from '@/components/event/QRCodeScanner'; +import { apiClient } from '@/services/api'; +import { CleanupEvent, EventAttendee } from '@/types'; + +export function EventCheckInPage() { + const { eventId } = useParams<{ eventId: string }>(); + const { user } = useUser(); + const navigate = useNavigate(); + const [event, setEvent] = useState(null); + const [attendees, setAttendees] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [checkedInUsers, setCheckedInUsers] = useState>(new Set()); + + useEffect(() => { + loadEventData(); + }, [eventId]); + + const loadEventData = async () => { + if (!eventId) return; + + try { + setLoading(true); + setError(null); + + // Load event details + const eventResponse = await apiClient.get(`/events/${eventId}`); + setEvent(eventResponse); + + // Check if user is the organizer + if (eventResponse.organizerClerkId !== user?.id) { + setError('Only the event organizer can access check-in'); + return; + } + + // Load attendees + const attendeesResponse = await apiClient.get( + `/events/${eventId}/attendees` + ); + setAttendees(attendeesResponse); + + // Track already checked-in users + const checkedIn = new Set( + attendeesResponse.filter((a) => a.attended).map((a) => a.userId) + ); + setCheckedInUsers(checkedIn); + } catch (err: any) { + console.error('Error loading event data:', err); + setError(err.message || 'Failed to load event data'); + } finally { + setLoading(false); + } + }; + + const handleScan = async (clerkUserId: string) => { + if (!eventId) return; + + try { + // Find the attendee by Clerk ID + const attendee = attendees.find((a) => { + // We need to match by Clerk ID, but we have user.id (database ID) + // We'll need to check the user object + return false; // This needs backend support + }); + + // For now, we'll use a different approach - send the Clerk ID to backend + // and let it handle the lookup + const response = await apiClient.post( + `/events/${eventId}/checkin`, + { clerkUserId } + ); + + setSuccessMessage(`Successfully checked in ${response.username || 'user'}`); + setCheckedInUsers((prev) => new Set([...prev, clerkUserId])); + + // Reload attendees to get updated status + setTimeout(() => { + loadEventData(); + setSuccessMessage(null); + }, 2000); + } catch (err: any) { + console.error('Error checking in user:', err); + setError(err.message || 'Failed to check in user'); + setTimeout(() => setError(null), 3000); + } + }; + + const handleScanError = (errorMsg: string) => { + setError(errorMsg); + setTimeout(() => setError(null), 3000); + }; + + if (loading) { + return ( +
+
+
+

Loading event...

+
+
+ ); + } + + if (error && !event) { + return ( +
+
+
+ + + +
+

+ Access Denied +

+

{error}

+ + Back to Events + +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + + + + Back to Event + +

Event Check-In

+ {event && ( +

{event.title}

+ )} +
+ + {/* Success Message */} + {successMessage && ( +
+
+ + + + {successMessage} +
+
+ )} + + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* QR Scanner */} +
+

+ Scan Attendee QR Code +

+ +
+ + {/* Attendee List */} +
+

+ Registered Attendees ({attendees.length}) +

+ {attendees.length === 0 ? ( +

+ No attendees registered yet +

+ ) : ( +
+ {attendees.map((attendee) => ( +
+
+ {attendee.user.avatarUrl ? ( + {attendee.user.username} + ) : ( +
+ {attendee.user.username.charAt(0).toUpperCase()} +
+ )} +
+

+ {attendee.user.firstName && attendee.user.lastName + ? `${attendee.user.firstName} ${attendee.user.lastName}` + : attendee.user.username} +

+

+ @{attendee.user.username} +

+
+
+ {attendee.attended && ( +
+ + + + Checked In +
+ )} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/pages/UserProfilePage.tsx b/src/pages/UserProfilePage.tsx index ccd8216..abb5996 100644 --- a/src/pages/UserProfilePage.tsx +++ b/src/pages/UserProfilePage.tsx @@ -6,6 +6,7 @@ import { userService, type UserProfile, type UserActivity } from '@/services/use import type { Report, CleanupEvent } from '@/types'; import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'; import { NotificationSettings, type NotificationPreferences, BadgeDisplay } from '@/components/auth'; +import { UserQRCode } from '@/components/user/UserQRCode'; import 'leaflet/dist/leaflet.css'; import L from 'leaflet'; @@ -420,6 +421,11 @@ export function UserProfilePage() {
{activeTab === 'overview' && (
+ {/* QR Code for own profile */} + {isOwnProfile && ( + + )} +

Contribution Map

{reports.length > 0 && reports[0] ? (