This MR fixes critical authentication issues that prevented login on localhost and improves the developer experience with consolidated rebuild scripts and a working help modal keyboard shortcut. (#5)
All checks were successful
CI / skip-ci-check (push) Successful in 1m25s
CI / lint-and-type-check (push) Successful in 1m50s
CI / test (push) Successful in 1m54s
CI / build (push) Successful in 1m54s
CI / secret-scanning (push) Successful in 1m26s
CI / dependency-scan (push) Successful in 1m31s
CI / sast-scan (push) Successful in 2m34s
CI / workflow-summary (push) Successful in 1m23s
All checks were successful
CI / skip-ci-check (push) Successful in 1m25s
CI / lint-and-type-check (push) Successful in 1m50s
CI / test (push) Successful in 1m54s
CI / build (push) Successful in 1m54s
CI / secret-scanning (push) Successful in 1m26s
CI / dependency-scan (push) Successful in 1m31s
CI / sast-scan (push) Successful in 2m34s
CI / workflow-summary (push) Successful in 1m23s
# Fix authentication issues and improve developer experience ## Summary This MR fixes critical authentication issues that prevented login on localhost and improves the developer experience with consolidated rebuild scripts and a working help modal keyboard shortcut. ## Problems Fixed ### 1. Authentication Issues - **UntrustedHost Error**: NextAuth v5 was rejecting localhost requests with "UntrustedHost: Host must be trusted" error - **Cookie Prefix Errors**: Cookies were being set with `__Host-` and `__Secure-` prefixes on HTTP (localhost), causing browser rejection - **MissingCSRF Error**: CSRF token cookies were not being set correctly due to cookie configuration issues ### 2. Help Modal Keyboard Shortcut - **Shift+? not working**: The help modal keyboard shortcut was not detecting the question mark key correctly ### 3. Developer Experience - **Multiple rebuild scripts**: Had several overlapping rebuild scripts that were confusing - **Unused code**: Removed unused `useSecureCookies` variable and misleading comments ## Changes Made ### Authentication Fixes (`lib/auth.ts`) - Set `trustHost: true` to fix UntrustedHost error (required for NextAuth v5) - Added explicit cookie configuration for HTTP (localhost) to prevent prefix errors: - Cookies use `secure: false` for HTTP - Cookie names without prefixes for HTTP - Let Auth.js defaults handle HTTPS (with prefixes and Secure flag) - Removed unused `useSecureCookies` variable - Simplified debug logging ### Help Modal Fix (`components/HelpModal.tsx`) - Fixed keyboard shortcut detection to properly handle Shift+? (Shift+/) - Updated help text to show correct shortcut (Shift+? instead of Ctrl+?) ### Developer Scripts - **Consolidated rebuild scripts**: Merged `CLEAN_REBUILD.sh`, `FIX_AND_RESTART.sh`, and `start-server.sh` into single `rebuild.sh` - **Added REBUILD.md**: Documentation for rebuild process - Removed redundant script files ### Code Cleanup - Removed unused `useSecureCookies` variable from `lib/auth.ts` - Removed misleading comment from `app/api/auth/[...nextauth]/route.ts` - Cleaned up verbose debug logging ## Technical Details ### Cookie Configuration The fix works by explicitly configuring cookies for HTTP environments: - **HTTP (localhost)**: Cookies without prefixes, `secure: false` - **HTTPS (production)**: Let Auth.js defaults handle (prefixes + Secure flag) This prevents NextAuth v5 from auto-detecting HTTPS from proxy headers and incorrectly adding cookie prefixes. ### Keyboard Shortcut The question mark key requires Shift+/ on most keyboards. The fix now properly detects: - `event.shiftKey && event.key === "/"` - `event.key === "?"` (fallback) - `event.code === "Slash" && event.shiftKey` (additional fallback) ## Testing - ✅ Login works on localhost (http://localhost:3000) - ✅ No cookie prefix errors in browser console - ✅ No UntrustedHost errors in server logs - ✅ Help modal opens/closes with Shift+? - ✅ Rebuild script works in both dev and prod modes ## Files Changed ### Modified - `lib/auth.ts` - Authentication configuration fixes - `components/HelpModal.tsx` - Keyboard shortcut fix - `app/api/auth/[...nextauth]/route.ts` - Removed misleading comment ### Added - `rebuild.sh` - Consolidated rebuild script - `REBUILD.md` - Rebuild documentation ## Migration Notes No database migrations or environment variable changes required. The fix works with existing configuration. ## Related Issues Fixes authentication issues preventing local development and testing. Reviewed-on: #5
This commit is contained in:
parent
dfc2ee978d
commit
df865dca41
3
.gitignore
vendored
3
.gitignore
vendored
@ -50,3 +50,6 @@ next-env.d.ts
|
||||
# Test coverage
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# Application logs
|
||||
*.log
|
||||
|
||||
@ -233,7 +233,7 @@ model Guess {
|
||||
**Flow:**
|
||||
1. User navigates to `/upload`
|
||||
2. Uploads photo file or enters photo URL and answer name
|
||||
3. Form submits to `POST /api/photos/upload` (file upload) or `POST /api/photos` (URL)
|
||||
3. Form submits to `POST /api/photos/upload` (supports both file and URL uploads)
|
||||
4. API route:
|
||||
- Verifies session
|
||||
- For file uploads:
|
||||
@ -253,8 +253,8 @@ model Guess {
|
||||
5. User redirected to photo detail page
|
||||
|
||||
**API Routes:**
|
||||
- `app/api/photos/upload/route.ts` - File upload endpoint
|
||||
- `app/api/photos/route.ts` - URL upload endpoint (legacy)
|
||||
- `app/api/photos/upload/route.ts` - Single photo upload endpoint (supports both file and URL uploads)
|
||||
- `app/api/photos/upload-multiple/route.ts` - Multiple photo upload endpoint
|
||||
- `app/api/uploads/[filename]/route.ts` - Serves uploaded files
|
||||
|
||||
**File Storage:**
|
||||
|
||||
335
IMPROVEMENTS.md
Normal file
335
IMPROVEMENTS.md
Normal file
@ -0,0 +1,335 @@
|
||||
# MirrorMatch - Improvement Ideas & Future Enhancements
|
||||
|
||||
This document contains ideas, suggestions, and potential enhancements to make MirrorMatch better, faster, and more professional.
|
||||
|
||||
## 🚀 Performance Optimizations
|
||||
|
||||
### Database & Query Optimization
|
||||
- [ ] **Add database indexes** - Create indexes on frequently queried fields (`Photo.createdAt`, `User.points`) to speed up queries
|
||||
- **Why:** As your database grows, queries without indexes become exponentially slower. Indexes can make queries 10-100x faster, especially for leaderboard and photo listing queries that run frequently.
|
||||
|
||||
- [ ] **Implement pagination** - Add pagination to photos list to handle large datasets efficiently
|
||||
- **Why:** Loading all photos at once becomes slow and memory-intensive as you scale. Pagination improves load times, reduces server load, and provides better UX by showing manageable chunks of data.
|
||||
|
||||
- [ ] **Database connection pooling** - Configure connection pooling to optimize database connections
|
||||
- **Why:** Without pooling, each request creates a new database connection, which is expensive. Connection pooling reuses connections, reducing latency and allowing your app to handle more concurrent users.
|
||||
|
||||
- [ ] **Cache leaderboard data** - Use Redis or in-memory cache with TTL (1-5 minutes) to reduce database load
|
||||
- **Why:** The leaderboard is queried frequently but changes infrequently. Caching it reduces database load by 90%+ and makes the page load instantly, improving user experience significantly.
|
||||
|
||||
- [ ] **Lazy loading for photos** - Load images as user scrolls to improve initial page load
|
||||
- **Why:** Loading all images upfront is slow and wastes bandwidth. Lazy loading makes pages load 3-5x faster and reduces server costs, especially important for mobile users.
|
||||
|
||||
- [ ] **Query optimization** - Use `select` to fetch only needed fields, reducing data transfer
|
||||
- **Why:** Fetching entire records when you only need a few fields wastes bandwidth and memory. This simple change can reduce data transfer by 50-80% and speed up queries.
|
||||
|
||||
### Frontend Performance
|
||||
- [ ] **Image optimization** - Use Next.js Image component with proper sizing and automatic format optimization
|
||||
- **Why:** Unoptimized images can be 5-10x larger than needed. Next.js Image automatically serves WebP/AVIF formats and resizes images, reducing bandwidth by 60-80% and improving page load times dramatically.
|
||||
|
||||
- [ ] **Image lazy loading** - Implement lazy loading for photo thumbnails to improve page load times
|
||||
- **Why:** Loading all thumbnails at once slows initial page load. Lazy loading defers off-screen images, making pages load 2-3x faster and improving Core Web Vitals scores.
|
||||
|
||||
- [ ] **Virtual scrolling** - Use virtual scrolling for large photo lists to maintain performance
|
||||
- **Why:** Rendering hundreds of DOM elements causes lag. Virtual scrolling only renders visible items, keeping the UI smooth even with thousands of photos.
|
||||
|
||||
- [ ] **Code splitting** - Implement route-based code splitting to reduce initial bundle size
|
||||
- **Why:** Large JavaScript bundles slow initial page load. Code splitting loads only what's needed per route, reducing initial bundle by 40-60% and improving Time to Interactive.
|
||||
|
||||
- [ ] **React.memo() optimization** - Wrap expensive components with React.memo() to prevent unnecessary re-renders
|
||||
- **Why:** Unnecessary re-renders waste CPU and cause UI lag. Memoization prevents re-renders when props haven't changed, making the UI more responsive, especially on lower-end devices.
|
||||
|
||||
- [ ] **Bundle size optimization** - Analyze and optimize bundle size using webpack-bundle-analyzer
|
||||
- **Why:** Large bundles slow page loads and hurt SEO. Identifying and removing unused code can reduce bundle size by 20-40%, improving load times and user experience.
|
||||
|
||||
- [ ] **Progressive image loading** - Show blur placeholder while images load, then transition to full image
|
||||
- **Why:** Blank spaces while images load feel slow. Progressive loading provides immediate visual feedback, making the app feel faster and more polished, improving perceived performance.
|
||||
|
||||
### API & Backend
|
||||
- [ ] **Rate limiting** - Add rate limiting to prevent abuse and protect API endpoints
|
||||
- **Why:** Without rate limiting, malicious users can overwhelm your server or abuse features. Rate limiting protects against DDoS, prevents spam, and ensures fair resource usage for all users.
|
||||
|
||||
- [ ] **Request caching** - Implement caching for static data and frequently accessed endpoints
|
||||
- **Why:** Many API calls return the same data repeatedly. Caching reduces database queries by 70-90%, lowers server costs, and makes responses 10-100x faster.
|
||||
|
||||
- [ ] **Compression middleware** - Add gzip/brotli compression to reduce response sizes
|
||||
- **Why:** Text responses (JSON, HTML) compress by 70-90%. Compression reduces bandwidth costs, speeds up transfers (especially on mobile), and improves user experience with minimal effort.
|
||||
|
||||
- [ ] **Email queue system** - Use Bull/BullMQ to queue email sending and prevent blocking
|
||||
- **Why:** Sending emails synchronously blocks requests and can cause timeouts. A queue system makes email sending non-blocking, improves response times, and provides retry logic for failed emails.
|
||||
|
||||
- [ ] **Batch photo uploads** - Optimize bulk photo upload operations for better performance
|
||||
- **Why:** Uploading photos one-by-one is slow and inefficient. Batch uploads reduce overhead, improve upload speeds by 2-3x, and provide better progress feedback to users.
|
||||
|
||||
- [ ] **CDN integration** - Add CDN for static assets and uploaded images to reduce server load
|
||||
- **Why:** Serving files from your server uses bandwidth and slows responses. A CDN serves files from locations closer to users, reducing latency by 50-80% and offloading server load.
|
||||
|
||||
## 🎨 UI/UX Enhancements
|
||||
|
||||
### User Interface
|
||||
- [ ] **Dark mode support** - Add dark mode toggle with user preference persistence
|
||||
- **Why:** Many users prefer dark mode for reduced eye strain, especially in low-light. It's become an expected feature and can increase user satisfaction and retention.
|
||||
|
||||
- [ ] **Mobile responsiveness** - Improve mobile experience with better touch targets and swipe gestures
|
||||
- **Why:** A significant portion of users access from mobile. Poor mobile UX leads to frustration and abandonment. Better touch targets and gestures make the app feel native and professional.
|
||||
|
||||
- [ ] **Loading skeletons** - Replace blank screens with loading skeletons for better perceived performance
|
||||
- **Why:** Blank screens make the app feel broken or slow. Skeletons show structure immediately, making wait times feel shorter and the app more responsive, improving user confidence.
|
||||
|
||||
- [ ] **Toast notifications** - Implement toast notifications for user feedback (success, error, info)
|
||||
- **Why:** Users need clear feedback for their actions. Toast notifications are non-intrusive, provide immediate feedback, and improve UX by clearly communicating success/error states.
|
||||
|
||||
- [ ] **Smooth page transitions** - Add smooth transitions between pages for better user experience
|
||||
- **Why:** Abrupt page changes feel jarring. Smooth transitions create a polished, professional feel and make navigation feel faster and more cohesive.
|
||||
|
||||
- [ ] **Inline form validation** - Show validation errors inline as user types for better feedback
|
||||
- **Why:** Waiting until form submission to show errors is frustrating. Inline validation provides immediate feedback, reduces errors, and improves form completion rates.
|
||||
|
||||
- [ ] **Keyboard navigation** - Add full keyboard navigation support for accessibility
|
||||
- **Why:** Keyboard navigation is essential for accessibility (WCAG compliance) and power users. It makes your app usable for people with disabilities and improves efficiency for all users.
|
||||
|
||||
- [ ] **Drag-and-drop uploads** - Allow users to drag and drop photos for easier uploads
|
||||
- **Why:** Drag-and-drop is faster and more intuitive than clicking "Choose File". It's a modern UX pattern users expect and can increase upload engagement.
|
||||
|
||||
- [ ] **Empty states** - Create better empty states for when there are no photos, guesses, etc.
|
||||
- **Why:** Empty screens confuse users. Well-designed empty states guide users on what to do next, reduce confusion, and make the app feel more polished and helpful.
|
||||
|
||||
- [ ] **Animations and micro-interactions** - Add subtle animations to improve user engagement
|
||||
- **Why:** Subtle animations provide visual feedback, make interactions feel responsive, and create a premium, polished feel that increases user satisfaction and engagement.
|
||||
|
||||
- [ ] **Infinite scroll** - Implement infinite scroll for photos list instead of pagination
|
||||
- **Why:** Infinite scroll feels more modern and seamless, especially on mobile. It reduces friction and can increase engagement by making it easier to browse through photos.
|
||||
|
||||
- [ ] **Photo filters and search** - Add filtering and search functionality to find photos easily
|
||||
- **Why:** As the photo library grows, finding specific photos becomes difficult. Filters and search help users quickly find what they're looking for, improving usability and engagement.
|
||||
|
||||
### User Experience
|
||||
- [ ] **Onboarding tour** - Create interactive tour for new users explaining key features
|
||||
- **Why:** New users often don't understand how to use the app. An onboarding tour reduces confusion, increases feature discovery, and improves user retention by helping users get value quickly.
|
||||
|
||||
- [ ] **Remember me option** - Add "Remember me" checkbox to login for longer session duration
|
||||
- **Why:** Frequent logouts frustrate users. A "Remember me" option improves convenience, reduces friction, and increases user satisfaction, especially for regular users.
|
||||
|
||||
- [ ] **Guess history display** - Show user's guess history for each photo (correct/wrong attempts)
|
||||
- **Why:** Users want to see their past attempts. Showing guess history provides context, helps users learn, and adds transparency that increases trust and engagement.
|
||||
|
||||
- [ ] **Already guessed indicator** - Display clear indicator when user has already guessed a photo
|
||||
- **Why:** Without indicators, users waste time trying to guess photos they've already attempted. Clear indicators prevent confusion and improve efficiency.
|
||||
|
||||
- [ ] **Photo tags system** - Allow users to tag photos for better organization and filtering
|
||||
- **Why:** Tags help organize and categorize photos, making them easier to find and filter. This improves discoverability and allows for better content organization as the library grows.
|
||||
|
||||
- [ ] **Photo descriptions/clues** - Add optional descriptions or clues to photos for hints
|
||||
- **Why:** Some photos are too difficult without context. Optional clues make the game more accessible, increase engagement, and allow for more creative photo challenges.
|
||||
|
||||
- [ ] **Confirmation dialogs** - Add confirmation dialogs for destructive actions (delete, etc.)
|
||||
- **Why:** Accidental deletions are frustrating and can't be undone. Confirmation dialogs prevent mistakes, protect user data, and reduce support requests from accidental actions.
|
||||
|
||||
- [ ] **Photo filtering** - Add filters to show: unguessed photos, correct guesses, wrong guesses, and photos by specific users
|
||||
- **Why:** Users want to focus on specific subsets of photos. Filtering helps users find unguessed photos to play, review their performance, or follow specific uploaders, significantly improving usability.
|
||||
|
||||
## 🔒 Security Improvements
|
||||
|
||||
- [ ] **CSRF protection** - Implement CSRF tokens for all state-changing operations
|
||||
- **Why:** CSRF attacks can trick users into performing unwanted actions. CSRF protection is a security best practice that prevents unauthorized state changes and protects user accounts.
|
||||
|
||||
- [ ] **Input sanitization** - Sanitize all user inputs to prevent XSS attacks
|
||||
- **Why:** Malicious scripts in user input can steal data or hijack sessions. Input sanitization prevents XSS attacks, protects user data, and is essential for any app accepting user input.
|
||||
|
||||
- [ ] **File type validation** - Validate actual file content (not just extension) to prevent malicious uploads
|
||||
- **Why:** Attackers can rename malicious files with safe extensions. Content validation ensures only legitimate image files are uploaded, preventing security vulnerabilities and server compromise.
|
||||
|
||||
- [ ] **Virus scanning** - Add virus scanning for uploaded files before storing
|
||||
- **Why:** Malicious files can harm your server or other users. Virus scanning protects your infrastructure and users, especially important if files are shared or downloaded by others.
|
||||
|
||||
- [ ] **Session timeout** - Implement automatic session timeout for inactive users
|
||||
- **Why:** Long-lived sessions increase security risk if devices are compromised. Session timeouts reduce risk of unauthorized access and are a security best practice for sensitive applications.
|
||||
|
||||
- [ ] **Security headers** - Add security headers (CSP, HSTS, X-Frame-Options, etc.)
|
||||
- **Why:** Security headers prevent common attacks (clickjacking, XSS, MITM). They're easy to implement, provide significant security benefits, and are recommended by security standards.
|
||||
|
||||
- [ ] **Audit logging** - Log all admin actions for security auditing and compliance
|
||||
- **Why:** Admin actions can have significant impact. Audit logs provide accountability, help detect unauthorized access, and are essential for security compliance and incident investigation.
|
||||
|
||||
## 📊 Features & Functionality
|
||||
|
||||
### Game Mechanics
|
||||
- [ ] **Streak tracking** - Track consecutive correct guesses and display current streak
|
||||
- **Why:** Streaks add excitement and encourage daily engagement. They create a sense of achievement and competition, increasing user retention and making the game more addictive.
|
||||
|
||||
- [ ] **Achievements/badges system** - Create achievement system with badges for milestones
|
||||
- **Why:** Achievements provide goals and recognition. They increase engagement, encourage exploration of features, and create a sense of progression that keeps users coming back.
|
||||
|
||||
- [ ] **Hints system** - Allow optional hints for photos (earn fewer points when using hints)
|
||||
- **Why:** Some photos are too difficult, leading to frustration. Hints make the game more accessible while still rewarding skill, increasing engagement and reducing abandonment.
|
||||
|
||||
### Social Features
|
||||
- [ ] **Photo comments** - Allow users to comment on photos
|
||||
- **Why:** Comments add social interaction and context. They increase engagement, create community, and make photos more interesting by adding discussion and stories.
|
||||
|
||||
- [ ] **Reactions/emojis** - Add emoji reactions to photos for quick feedback
|
||||
- **Why:** Reactions are quick, low-friction ways to engage. They increase interaction rates, add fun, and provide feedback to uploaders without requiring comments.
|
||||
|
||||
- [ ] **User mentions** - Support @username mentions in comments
|
||||
- **Why:** Mentions notify users and create conversations. They increase engagement, enable discussions, and help build community by connecting users.
|
||||
|
||||
- [ ] **Activity feed** - Display recent activity feed (guesses, uploads, achievements)
|
||||
- **Why:** Activity feeds show what's happening in real-time. They increase engagement, help users discover new content, and create a sense of community and activity.
|
||||
|
||||
### Admin Features
|
||||
- [ ] **User activity logs dashboard** - Create admin dashboard showing user activity logs
|
||||
- **Why:** Admins need visibility into user behavior. Activity logs help identify issues, understand usage patterns, detect abuse, and make data-driven decisions about the platform.
|
||||
|
||||
- [ ] **Analytics dashboard** - Build analytics dashboard showing uploads, guesses, engagement metrics
|
||||
- **Why:** Data-driven decisions require visibility. Analytics help understand what's working, identify trends, measure growth, and optimize features based on actual usage.
|
||||
|
||||
- [ ] **System health monitoring** - Add system health monitoring and status page
|
||||
- **Why:** Proactive monitoring prevents downtime. Health checks help catch issues before users notice, reduce support requests, and provide transparency about system status.
|
||||
|
||||
- [ ] **Backup/export functionality** - Create tools to backup and export data
|
||||
- **Why:** Data loss is catastrophic. Backups protect against disasters, and export functionality helps with migrations, compliance, and gives users control over their data.
|
||||
|
||||
### Photo Management
|
||||
- [ ] **Photo editing tools** - Add basic editing (crop, rotate, filters) before upload
|
||||
- **Why:** Users often need to adjust photos before uploading. Built-in editing reduces friction, improves photo quality, and eliminates the need for external editing tools.
|
||||
|
||||
- [ ] **Improved duplicate detection** - Enhance duplicate photo detection beyond hash matching
|
||||
- **Why:** Current hash matching only catches exact duplicates. Better detection (visual similarity) prevents near-duplicates, keeps content fresh, and improves user experience.
|
||||
|
||||
- [ ] **Photo tagging system** - Implement tagging system for better organization
|
||||
- **Why:** Tags enable better organization and discovery. They make it easier to find photos, create themed collections, and improve search functionality as content grows.
|
||||
|
||||
- [ ] **Photo search functionality** - Add full-text search for photos by tags, descriptions, answers
|
||||
- **Why:** As the library grows, finding photos becomes difficult. Search enables quick discovery, improves usability, and is essential for a good user experience at scale.
|
||||
|
||||
- [ ] **Photo archiving** - Allow archiving old photos while keeping them accessible
|
||||
- **Why:** Old photos clutter the main view but shouldn't be deleted. Archiving keeps content organized, maintains history, and improves the browsing experience for active content.
|
||||
|
||||
- [ ] **Photo approval workflow** - Add optional admin approval workflow (can be toggled on/off)
|
||||
- **Why:** Content moderation ensures quality and safety. An approval workflow lets admins control what goes public, prevents inappropriate content, and maintains content standards.
|
||||
|
||||
## 🧪 Testing & Quality
|
||||
|
||||
- [ ] **Increase test coverage** - Expand test coverage to all critical paths and edge cases
|
||||
- **Why:** Bugs in production are expensive and damage trust. Comprehensive tests catch issues early, reduce regressions, and give confidence when making changes.
|
||||
|
||||
- [ ] **E2E tests** - Add end-to-end tests using Playwright or Cypress
|
||||
- **Why:** Unit tests don't catch integration issues. E2E tests verify the full user flow works, catch real-world bugs, and prevent breaking changes from reaching users.
|
||||
|
||||
- [ ] **Visual regression testing** - Implement visual regression testing to catch UI changes
|
||||
- **Why:** UI bugs are easy to miss in code review. Visual regression tests catch unintended visual changes, ensure consistency, and save time in QA.
|
||||
|
||||
- [ ] **Performance testing** - Add Lighthouse CI for continuous performance monitoring
|
||||
- **Why:** Performance degrades over time. Automated performance testing catches regressions early, ensures good Core Web Vitals, and maintains fast user experience.
|
||||
|
||||
- [ ] **Load testing** - Create load testing scenarios to test system under stress
|
||||
- **Why:** Systems behave differently under load. Load testing reveals bottlenecks, ensures the app can handle traffic spikes, and prevents crashes during high usage.
|
||||
|
||||
- [ ] **API integration tests** - Add comprehensive integration tests for all API routes
|
||||
- **Why:** API bugs affect all clients. Integration tests verify API contracts, catch breaking changes, and ensure APIs work correctly with the database and auth.
|
||||
|
||||
- [ ] **Accessibility testing** - Implement automated accessibility testing (a11y)
|
||||
- **Why:** Accessibility is a legal requirement and moral obligation. Automated testing catches a11y issues early, ensures WCAG compliance, and makes the app usable for everyone.
|
||||
|
||||
- [ ] **Error boundaries** - Add React error boundaries to gracefully handle component errors
|
||||
- **Why:** One component error shouldn't crash the entire app. Error boundaries isolate failures, show helpful error messages, and keep the rest of the app functional.
|
||||
|
||||
- [ ] **Error logging and monitoring** - Create comprehensive error logging and monitoring system
|
||||
- **Why:** You can't fix what you can't see. Error monitoring catches production bugs, provides context for debugging, and helps prioritize fixes based on impact.
|
||||
|
||||
## 🔧 Technical Improvements
|
||||
|
||||
### Architecture
|
||||
- [ ] **Microservices migration** - Consider migrating to microservices if scale requires it
|
||||
- **Why:** Monoliths become hard to scale and maintain at large scale. Microservices enable independent scaling, faster deployments, and better fault isolation, but only if needed.
|
||||
|
||||
- [ ] **Event-driven architecture** - Implement event-driven architecture for notifications
|
||||
- **Why:** Tight coupling makes systems brittle. Event-driven architecture decouples components, improves scalability, and makes it easier to add new features without modifying existing code.
|
||||
|
||||
- [ ] **Message queue** - Add message queue (RabbitMQ, Redis Queue) for async operations
|
||||
- **Why:** Synchronous operations block requests. Message queues enable async processing, improve response times, provide retry logic, and handle traffic spikes better.
|
||||
|
||||
- [ ] **GraphQL API** - Consider GraphQL API for more flexible querying
|
||||
- **Why:** REST APIs often over-fetch or under-fetch data. GraphQL lets clients request exactly what they need, reduces bandwidth, and simplifies frontend development.
|
||||
|
||||
- [ ] **API versioning** - Implement API versioning for backward compatibility
|
||||
- **Why:** API changes break clients. Versioning allows gradual migration, prevents breaking changes, and enables supporting multiple client versions simultaneously.
|
||||
|
||||
- [ ] **Health check endpoints** - Add health check endpoints for monitoring
|
||||
- **Why:** Monitoring systems need to check if the app is healthy. Health endpoints enable automated monitoring, load balancer health checks, and quick status verification.
|
||||
|
||||
- [ ] **Monitoring and alerting** - Create comprehensive monitoring and alerting system
|
||||
- **Why:** Issues caught early are easier to fix. Monitoring provides visibility, alerts notify of problems immediately, and helps maintain uptime and performance.
|
||||
|
||||
### Code Quality
|
||||
- [ ] **ESLint strict rules** - Add stricter ESLint rules for better code quality
|
||||
- **Why:** Code quality issues lead to bugs. Stricter linting catches problems early, enforces best practices, and maintains consistent code style across the team.
|
||||
|
||||
- [ ] **Prettier formatting** - Implement Prettier for consistent code formatting
|
||||
- **Why:** Inconsistent formatting wastes time in reviews. Prettier automates formatting, eliminates style debates, and ensures consistent code across the codebase.
|
||||
|
||||
- [ ] **Pre-commit hooks** - Add Husky pre-commit hooks to run linters and tests
|
||||
- **Why:** Catching issues before commit saves time. Pre-commit hooks prevent bad code from entering the repo, enforce quality standards, and reduce CI failures.
|
||||
|
||||
- [ ] **Component library/storybook** - Create component library with Storybook documentation
|
||||
- **Why:** Reusable components reduce duplication. Storybook documents components, enables isolated development, and helps maintain design consistency.
|
||||
|
||||
- [ ] **TypeScript strict mode** - Enable TypeScript strict mode for better type safety
|
||||
- **Why:** Type errors cause runtime bugs. Strict mode catches more errors at compile time, improves code quality, and provides better IDE support and refactoring safety.
|
||||
|
||||
- [ ] **Error tracking** - Integrate error tracking (Sentry, LogRocket) for production monitoring
|
||||
- **Why:** Production errors are hard to debug without context. Error tracking provides stack traces, user context, and helps prioritize and fix bugs quickly.
|
||||
|
||||
- [ ] **Performance monitoring** - Add performance monitoring (New Relic, Datadog)
|
||||
- **Why:** Slow performance hurts user experience. Performance monitoring identifies bottlenecks, tracks metrics over time, and helps optimize critical paths.
|
||||
|
||||
### DevOps & Infrastructure
|
||||
- [ ] **CI/CD pipeline** - Set up continuous integration and deployment pipeline
|
||||
- **Why:** Manual deployments are error-prone and slow. CI/CD automates testing and deployment, reduces human error, enables faster releases, and improves confidence in changes.
|
||||
|
||||
- [ ] **Automated deployments** - Implement automated deployments on merge to main
|
||||
- **Why:** Manual deployments delay releases and can be forgotten. Automation ensures consistent deployments, reduces downtime, and enables rapid iteration.
|
||||
|
||||
- [ ] **Blue-green deployments** - Set up blue-green deployment strategy for zero downtime
|
||||
- **Why:** Deployments cause downtime and risk. Blue-green deployments enable zero-downtime updates, instant rollbacks, and reduce deployment risk significantly.
|
||||
|
||||
- [ ] **Database migration strategy** - Create proper database migration strategy and rollback plan
|
||||
- **Why:** Database changes are risky and hard to undo. A migration strategy ensures safe, reversible changes and prevents data loss or corruption during updates.
|
||||
|
||||
- [ ] **Staging environment** - Create staging environment for testing before production
|
||||
- **Why:** Testing in production is dangerous. Staging mirrors production, allows safe testing, and catches issues before they affect real users.
|
||||
|
||||
- [ ] **Automated backups** - Implement automated database and file backups
|
||||
- **Why:** Data loss is catastrophic. Automated backups protect against disasters, enable recovery, and are essential for any production system.
|
||||
|
||||
- [ ] **Monitoring and logging** - Set up ELK stack or Grafana for centralized logging and monitoring
|
||||
- **Why:** Logs are essential for debugging and monitoring. Centralized logging makes logs searchable, enables alerting, and provides visibility into system behavior.
|
||||
|
||||
- [ ] **Container orchestration** - Set up Docker and Kubernetes for container orchestration
|
||||
- **Why:** Containers provide consistency and scalability. Kubernetes enables easy scaling, rolling updates, and manages infrastructure, making deployment and operations easier.
|
||||
|
||||
---
|
||||
|
||||
## Priority Recommendations
|
||||
|
||||
### High Priority (Quick Wins)
|
||||
1. **Image Optimization** - Use Next.js Image component for automatic optimization
|
||||
3. **Loading States** - Add loading skeletons for better UX
|
||||
4. **Toast Notifications** - Implement toast notifications for user feedback
|
||||
5. **Error Boundaries** - Add error boundaries for graceful error handling
|
||||
6. **Rate Limiting** - Add rate limiting to prevent abuse
|
||||
|
||||
### Medium Priority (Significant Impact)
|
||||
1. **Caching** - Implement Redis caching for leaderboard and frequently accessed data
|
||||
2. **Dark Mode** - Add dark mode support for user preference
|
||||
3. **Search Functionality** - Add photo search and filtering capabilities
|
||||
4. **Achievements System** - Implement achievements/badges for increased engagement
|
||||
5. **Analytics Dashboard** - Create admin analytics dashboard for insights
|
||||
|
||||
### Low Priority (Nice to Have)
|
||||
1. **Social Features** - Add comments, reactions, and activity feed
|
||||
2. **Advanced Gamification** - Implement levels, ranks, and advanced gamification features
|
||||
|
||||
---
|
||||
|
||||
**Note:** This is a living document. Add new ideas as they come up, and mark items as completed when implemented.
|
||||
@ -166,12 +166,17 @@ npm start
|
||||
- Photos are uploaded to `public/uploads/` directory
|
||||
- Files are served via `/api/uploads/[filename]` API route
|
||||
- Ensure the uploads directory has proper write permissions
|
||||
|
||||
**Upload Endpoints:**
|
||||
- `POST /api/photos/upload` - Single photo upload (supports both file and URL uploads)
|
||||
- `POST /api/photos/upload-multiple` - Multiple photo uploads in batch (used by upload page)
|
||||
- Files are stored on the filesystem (not in database)
|
||||
|
||||
**Monitoring Activity:**
|
||||
- User activity is logged to console/systemd logs
|
||||
- Watch logs in real-time: `sudo journalctl -u app-backend -f | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]"`
|
||||
- Activity logs include: page visits, photo uploads, guess submissions
|
||||
- **Note:** For local development, use `./watch-activity.sh` script (if systemd/journalctl is not available, check application logs directly)
|
||||
|
||||
## Database Commands
|
||||
|
||||
|
||||
69
REBUILD.md
Normal file
69
REBUILD.md
Normal file
@ -0,0 +1,69 @@
|
||||
# Rebuild Scripts
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Production Mode (Recommended for testing)
|
||||
```bash
|
||||
./rebuild.sh prod
|
||||
# or just
|
||||
./rebuild.sh
|
||||
```
|
||||
|
||||
### Development Mode (Hot reload)
|
||||
```bash
|
||||
./rebuild.sh dev
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. **Kills all processes** - Stops any running Node/Next.js processes
|
||||
2. **Frees ports** - Ensures ports 3000 and 3003 are available
|
||||
3. **Cleans build artifacts** - Removes `.next`, cache files, etc.
|
||||
4. **Rebuilds** (production only) - Runs `npm run build`
|
||||
5. **Starts server** - Runs in foreground (dev) or background (prod)
|
||||
|
||||
## Viewing Logs
|
||||
|
||||
### Production Mode
|
||||
```bash
|
||||
tail -f /tmp/mirrormatch-server.log
|
||||
```
|
||||
|
||||
### Development Mode
|
||||
Logs appear directly in the terminal (foreground mode)
|
||||
|
||||
### Watching Activity Logs
|
||||
```bash
|
||||
# If using rebuild.sh (production mode)
|
||||
./watch-activity.sh /tmp/mirrormatch-server.log
|
||||
|
||||
# If using systemd service
|
||||
./watch-activity.sh
|
||||
|
||||
# Or specify custom log file
|
||||
./watch-activity.sh /path/to/your/logfile.log
|
||||
```
|
||||
|
||||
## Manual Commands
|
||||
|
||||
If you prefer to run commands manually:
|
||||
|
||||
```bash
|
||||
# Kill everything
|
||||
sudo fuser -k 3000/tcp
|
||||
killall -9 node
|
||||
pkill -f "next"
|
||||
sleep 2
|
||||
|
||||
# Clean
|
||||
cd /home/beast/Code/mirrormatch
|
||||
rm -rf .next node_modules/.cache
|
||||
|
||||
# Rebuild (production)
|
||||
npm run build
|
||||
|
||||
# Start
|
||||
NODE_ENV=production npm run start > /tmp/server.log 2>&1 &
|
||||
# or for dev
|
||||
NODE_ENV=development npm run dev
|
||||
```
|
||||
117
__tests__/components/HelpModal.test.tsx
Normal file
117
__tests__/components/HelpModal.test.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react"
|
||||
import HelpModal from "@/components/HelpModal"
|
||||
|
||||
describe("HelpModal", () => {
|
||||
it("should not render initially", () => {
|
||||
render(<HelpModal />)
|
||||
expect(screen.queryByText("Welcome to MirrorMatch")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should open when Shift+? is pressed", () => {
|
||||
render(<HelpModal />)
|
||||
|
||||
// Simulate Shift+? (Shift+/)
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, {
|
||||
key: "/",
|
||||
shiftKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByText("Welcome to MirrorMatch")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should close when Escape is pressed", () => {
|
||||
render(<HelpModal />)
|
||||
|
||||
// Open modal first
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, {
|
||||
key: "/",
|
||||
shiftKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByText("Welcome to MirrorMatch")).toBeInTheDocument()
|
||||
|
||||
// Close with Escape
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, {
|
||||
key: "Escape",
|
||||
})
|
||||
})
|
||||
|
||||
// Modal should close
|
||||
expect(screen.queryByText("Welcome to MirrorMatch")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not open with Ctrl+? (should require Shift only)", () => {
|
||||
render(<HelpModal />)
|
||||
|
||||
// Simulate Ctrl+? (Ctrl+Shift+/)
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, {
|
||||
key: "/",
|
||||
shiftKey: true,
|
||||
ctrlKey: true, // Should prevent opening
|
||||
metaKey: false,
|
||||
})
|
||||
})
|
||||
|
||||
// Modal should not open when Ctrl is also pressed
|
||||
expect(screen.queryByText("Welcome to MirrorMatch")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should toggle when Shift+? is pressed multiple times", () => {
|
||||
render(<HelpModal />)
|
||||
|
||||
// First press - should open
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, {
|
||||
key: "/",
|
||||
shiftKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.getByText("Welcome to MirrorMatch")).toBeInTheDocument()
|
||||
|
||||
// Second press - should close
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, {
|
||||
key: "/",
|
||||
shiftKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.queryByText("Welcome to MirrorMatch")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should display help content when open", () => {
|
||||
render(<HelpModal />)
|
||||
|
||||
// Open modal
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, {
|
||||
key: "/",
|
||||
shiftKey: true,
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
})
|
||||
})
|
||||
|
||||
// Check for key content sections
|
||||
expect(screen.getByText("What is MirrorMatch?")).toBeInTheDocument()
|
||||
expect(screen.getByText("How to Play")).toBeInTheDocument()
|
||||
expect(screen.getByText("Key Features")).toBeInTheDocument()
|
||||
expect(screen.getByText("Keyboard Shortcuts")).toBeInTheDocument()
|
||||
expect(screen.getByText("Tips")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
209
__tests__/lib/activity-log.test.ts
Normal file
209
__tests__/lib/activity-log.test.ts
Normal file
@ -0,0 +1,209 @@
|
||||
import { logActivity } from '@/lib/activity-log';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
// Mock the logger
|
||||
jest.mock('@/lib/logger', () => ({
|
||||
logger: {
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Helper to create a mock Request object
|
||||
function createMockRequest(headers: Record<string, string> = {}): Request {
|
||||
const mockHeaders = new Headers();
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
mockHeaders.set(key, value);
|
||||
});
|
||||
|
||||
return {
|
||||
headers: mockHeaders,
|
||||
} as unknown as Request;
|
||||
}
|
||||
|
||||
describe('activity-log', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('logActivity', () => {
|
||||
it('should create activity log with all fields', () => {
|
||||
const mockRequest = createMockRequest({
|
||||
'x-forwarded-for': '192.168.1.1',
|
||||
});
|
||||
|
||||
const user = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
const details = { photoId: 'photo-456' };
|
||||
|
||||
const result = logActivity(
|
||||
'PHOTO_UPLOAD',
|
||||
'/api/photos/upload',
|
||||
'POST',
|
||||
user,
|
||||
details,
|
||||
mockRequest
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
action: 'PHOTO_UPLOAD',
|
||||
path: '/api/photos/upload',
|
||||
method: 'POST',
|
||||
userId: 'user-123',
|
||||
userEmail: 'test@example.com',
|
||||
userRole: 'USER',
|
||||
ip: '192.168.1.1',
|
||||
details: { photoId: 'photo-456' },
|
||||
});
|
||||
expect(result.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle unauthenticated users', () => {
|
||||
const result = logActivity(
|
||||
'PAGE_VIEW',
|
||||
'/photos',
|
||||
'GET',
|
||||
null,
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
action: 'PAGE_VIEW',
|
||||
path: '/photos',
|
||||
method: 'GET',
|
||||
userId: undefined,
|
||||
userEmail: undefined,
|
||||
userRole: undefined,
|
||||
ip: 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract IP from x-forwarded-for header', () => {
|
||||
const mockRequest = createMockRequest({
|
||||
'x-forwarded-for': '192.168.1.1, 10.0.0.1',
|
||||
});
|
||||
|
||||
const result = logActivity(
|
||||
'ACTION',
|
||||
'/path',
|
||||
'GET',
|
||||
undefined,
|
||||
undefined,
|
||||
mockRequest
|
||||
);
|
||||
|
||||
expect(result.ip).toBe('192.168.1.1');
|
||||
});
|
||||
|
||||
it('should extract IP from x-real-ip header when x-forwarded-for is missing', () => {
|
||||
const mockRequest = createMockRequest({
|
||||
'x-real-ip': '10.0.0.1',
|
||||
});
|
||||
|
||||
const result = logActivity(
|
||||
'ACTION',
|
||||
'/path',
|
||||
'GET',
|
||||
undefined,
|
||||
undefined,
|
||||
mockRequest
|
||||
);
|
||||
|
||||
expect(result.ip).toBe('10.0.0.1');
|
||||
});
|
||||
|
||||
it('should use "unknown" for IP when no headers are present', () => {
|
||||
const mockRequest = createMockRequest();
|
||||
|
||||
const result = logActivity(
|
||||
'ACTION',
|
||||
'/path',
|
||||
'GET',
|
||||
undefined,
|
||||
undefined,
|
||||
mockRequest
|
||||
);
|
||||
|
||||
expect(result.ip).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should call logger.info with structured data', () => {
|
||||
const user = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
const details = { photoId: 'photo-456' };
|
||||
|
||||
logActivity(
|
||||
'PHOTO_UPLOAD',
|
||||
'/api/photos/upload',
|
||||
'POST',
|
||||
user,
|
||||
details
|
||||
);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Activity: PHOTO_UPLOAD',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
path: '/api/photos/upload',
|
||||
userId: 'user-123',
|
||||
userEmail: 'test@example.com',
|
||||
userRole: 'USER',
|
||||
details: { photoId: 'photo-456' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include details in logger call when details are not provided', () => {
|
||||
const user = {
|
||||
id: 'user-123',
|
||||
email: 'test@example.com',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
logActivity(
|
||||
'PAGE_VIEW',
|
||||
'/photos',
|
||||
'GET',
|
||||
user
|
||||
);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'Activity: PAGE_VIEW',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
path: '/photos',
|
||||
userId: 'user-123',
|
||||
userEmail: 'test@example.com',
|
||||
userRole: 'USER',
|
||||
})
|
||||
);
|
||||
|
||||
const callArgs = (logger.info as jest.Mock).mock.calls[0][1];
|
||||
expect(callArgs).not.toHaveProperty('details');
|
||||
});
|
||||
|
||||
it('should handle empty details object', () => {
|
||||
const result = logActivity(
|
||||
'ACTION',
|
||||
'/path',
|
||||
'GET',
|
||||
undefined,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(result.details).toEqual({});
|
||||
expect(logger.info).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
210
__tests__/lib/logger.test.ts
Normal file
210
__tests__/lib/logger.test.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { logger, LogLevel, getLogLevel, formatLog, createLogger } from '@/lib/logger';
|
||||
|
||||
// Mock console methods
|
||||
const originalConsole = { ...console };
|
||||
const mockConsole = {
|
||||
log: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
describe('Logger', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
console.log = mockConsole.log;
|
||||
console.warn = mockConsole.warn;
|
||||
console.error = mockConsole.error;
|
||||
// Reset environment variables
|
||||
process.env = { ...originalEnv };
|
||||
// Use type assertion to allow deletion
|
||||
delete (process.env as { LOG_LEVEL?: string }).LOG_LEVEL;
|
||||
delete (process.env as { LOG_FORMAT?: string }).LOG_FORMAT;
|
||||
delete (process.env as { NODE_ENV?: string }).NODE_ENV;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.log = originalConsole.log;
|
||||
console.warn = originalConsole.warn;
|
||||
console.error = originalConsole.error;
|
||||
});
|
||||
|
||||
describe('getLogLevel', () => {
|
||||
it('should return DEBUG when LOG_LEVEL=DEBUG', () => {
|
||||
process.env.LOG_LEVEL = 'DEBUG';
|
||||
expect(getLogLevel()).toBe(LogLevel.DEBUG);
|
||||
});
|
||||
|
||||
it('should return INFO when LOG_LEVEL=INFO', () => {
|
||||
process.env.LOG_LEVEL = 'INFO';
|
||||
expect(getLogLevel()).toBe(LogLevel.INFO);
|
||||
});
|
||||
|
||||
it('should return WARN when LOG_LEVEL=WARN', () => {
|
||||
process.env.LOG_LEVEL = 'WARN';
|
||||
expect(getLogLevel()).toBe(LogLevel.WARN);
|
||||
});
|
||||
|
||||
it('should return ERROR when LOG_LEVEL=ERROR', () => {
|
||||
process.env.LOG_LEVEL = 'ERROR';
|
||||
expect(getLogLevel()).toBe(LogLevel.ERROR);
|
||||
});
|
||||
|
||||
it('should return NONE when LOG_LEVEL=NONE', () => {
|
||||
process.env.LOG_LEVEL = 'NONE';
|
||||
expect(getLogLevel()).toBe(LogLevel.NONE);
|
||||
});
|
||||
|
||||
it('should default to DEBUG in development', () => {
|
||||
(process.env as { NODE_ENV?: string }).NODE_ENV = 'development';
|
||||
expect(getLogLevel()).toBe(LogLevel.DEBUG);
|
||||
});
|
||||
|
||||
it('should default to INFO in production', () => {
|
||||
(process.env as { NODE_ENV?: string }).NODE_ENV = 'production';
|
||||
expect(getLogLevel()).toBe(LogLevel.INFO);
|
||||
});
|
||||
|
||||
it('should ignore invalid LOG_LEVEL values and use defaults', () => {
|
||||
(process.env as { LOG_LEVEL?: string }).LOG_LEVEL = 'INVALID';
|
||||
(process.env as { NODE_ENV?: string }).NODE_ENV = 'production';
|
||||
expect(getLogLevel()).toBe(LogLevel.INFO);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatLog', () => {
|
||||
it('should format log in human-readable format by default', () => {
|
||||
const result = formatLog(LogLevel.INFO, 'Test message', { key: 'value' });
|
||||
expect(result).toContain('[INFO]');
|
||||
expect(result).toContain('Test message');
|
||||
expect(result).toContain('{"key":"value"}');
|
||||
});
|
||||
|
||||
it('should format log as JSON when LOG_FORMAT=json', () => {
|
||||
process.env.LOG_FORMAT = 'json';
|
||||
const result = formatLog(LogLevel.INFO, 'Test message', { key: 'value' });
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.level).toBe('INFO');
|
||||
expect(parsed.message).toBe('Test message');
|
||||
expect(parsed.key).toBe('value');
|
||||
expect(parsed.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
it('should format Error objects correctly', () => {
|
||||
const error = new Error('Test error');
|
||||
const result = formatLog(LogLevel.ERROR, 'Error occurred', error);
|
||||
expect(result).toContain('[ERROR]');
|
||||
expect(result).toContain('Error occurred');
|
||||
expect(result).toContain('Error: Error: Test error');
|
||||
});
|
||||
|
||||
it('should format Error objects as JSON when LOG_FORMAT=json', () => {
|
||||
process.env.LOG_FORMAT = 'json';
|
||||
const error = new Error('Test error');
|
||||
const result = formatLog(LogLevel.ERROR, 'Error occurred', error);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.level).toBe('ERROR');
|
||||
expect(parsed.message).toBe('Error occurred');
|
||||
expect(parsed.error.name).toBe('Error');
|
||||
expect(parsed.error.message).toBe('Test error');
|
||||
expect(parsed.error.stack).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle logs without context', () => {
|
||||
const result = formatLog(LogLevel.INFO, 'Simple message');
|
||||
expect(result).toContain('[INFO]');
|
||||
expect(result).toContain('Simple message');
|
||||
// Format always includes pipe separator, but no context data after it
|
||||
expect(result).toContain('|');
|
||||
expect(result.split('|').length).toBe(2); // timestamp | message (no context)
|
||||
});
|
||||
});
|
||||
|
||||
describe('Logger instance', () => {
|
||||
it('should log DEBUG messages when level is DEBUG', () => {
|
||||
process.env.LOG_LEVEL = 'DEBUG';
|
||||
const testLogger = createLogger();
|
||||
testLogger.debug('Debug message', { data: 'test' });
|
||||
expect(mockConsole.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log DEBUG messages when level is INFO', () => {
|
||||
process.env.LOG_LEVEL = 'INFO';
|
||||
const testLogger = createLogger();
|
||||
testLogger.debug('Debug message');
|
||||
expect(mockConsole.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log INFO messages when level is INFO', () => {
|
||||
process.env.LOG_LEVEL = 'INFO';
|
||||
const testLogger = createLogger();
|
||||
testLogger.info('Info message');
|
||||
expect(mockConsole.log).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log WARN messages when level is WARN', () => {
|
||||
process.env.LOG_LEVEL = 'WARN';
|
||||
const testLogger = createLogger();
|
||||
testLogger.warn('Warning message');
|
||||
expect(mockConsole.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log INFO messages when level is WARN', () => {
|
||||
process.env.LOG_LEVEL = 'WARN';
|
||||
const testLogger = createLogger();
|
||||
testLogger.info('Info message');
|
||||
expect(mockConsole.log).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should log ERROR messages when level is ERROR', () => {
|
||||
process.env.LOG_LEVEL = 'ERROR';
|
||||
const testLogger = createLogger();
|
||||
testLogger.error('Error message');
|
||||
expect(mockConsole.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not log any messages when level is NONE', () => {
|
||||
process.env.LOG_LEVEL = 'NONE';
|
||||
const testLogger = createLogger();
|
||||
testLogger.debug('Debug message');
|
||||
testLogger.info('Info message');
|
||||
testLogger.warn('Warning message');
|
||||
testLogger.error('Error message');
|
||||
expect(mockConsole.log).not.toHaveBeenCalled();
|
||||
expect(mockConsole.warn).not.toHaveBeenCalled();
|
||||
expect(mockConsole.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Error objects in error method', () => {
|
||||
process.env.LOG_LEVEL = 'ERROR';
|
||||
const testLogger = createLogger();
|
||||
const error = new Error('Test error');
|
||||
testLogger.error('Error occurred', error);
|
||||
expect(mockConsole.error).toHaveBeenCalled();
|
||||
const callArgs = mockConsole.error.mock.calls[0][0];
|
||||
expect(callArgs).toContain('Error occurred');
|
||||
});
|
||||
|
||||
it('isLevelEnabled should return correct values', () => {
|
||||
process.env.LOG_LEVEL = 'WARN';
|
||||
const testLogger = createLogger();
|
||||
expect(testLogger.isLevelEnabled(LogLevel.DEBUG)).toBe(false);
|
||||
expect(testLogger.isLevelEnabled(LogLevel.INFO)).toBe(false);
|
||||
expect(testLogger.isLevelEnabled(LogLevel.WARN)).toBe(true);
|
||||
expect(testLogger.isLevelEnabled(LogLevel.ERROR)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Default logger instance', () => {
|
||||
it('should be available and functional', () => {
|
||||
process.env.LOG_LEVEL = 'INFO';
|
||||
logger.info('Test message');
|
||||
expect(mockConsole.log).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,12 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { hashPassword } from "@/lib/utils"
|
||||
|
||||
// Mark this route as dynamic to prevent build-time data collection
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ userId: string }> }
|
||||
) {
|
||||
let userId: string | undefined
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
@ -14,7 +19,7 @@ export async function POST(
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { userId } = await params
|
||||
userId = (await params).userId
|
||||
const { password } = await req.json()
|
||||
|
||||
if (!password || password.length < 6) {
|
||||
@ -33,7 +38,10 @@ export async function POST(
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Error resetting password:", error)
|
||||
logger.error("Error resetting password", {
|
||||
userId,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@ -2,6 +2,10 @@ import { NextRequest, NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { hashPassword } from "@/lib/utils"
|
||||
import { logger } from "@/lib/logger"
|
||||
|
||||
// Mark this route as dynamic to prevent build-time data collection
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@ -53,7 +57,9 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error)
|
||||
logger.error("Error creating user", {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
import { handlers } from "@/lib/auth"
|
||||
|
||||
// Mark this route as dynamic to prevent build-time data collection
|
||||
// NextAuth requires runtime environment variables and cannot be pre-rendered
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export const { GET, POST } = handlers
|
||||
|
||||
@ -1,9 +1,32 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/constants"
|
||||
import { logger } from "@/lib/logger"
|
||||
|
||||
// Mark this route as dynamic to prevent build-time data collection
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
/**
|
||||
* Debug endpoint for session inspection
|
||||
* ADMIN ONLY - Protected endpoint for debugging session issues
|
||||
*
|
||||
* This endpoint should only be accessible to administrators.
|
||||
* Consider removing in production or restricting further.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Require admin authentication
|
||||
const session = await auth()
|
||||
|
||||
if (!session || session.user.role !== "ADMIN") {
|
||||
logger.warn("Unauthorized access attempt to debug endpoint", {
|
||||
userId: session?.user?.id,
|
||||
userRole: session?.user?.role,
|
||||
path: "/api/debug/session",
|
||||
})
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
const cookieHeader = request.headers.get("cookie") || ""
|
||||
|
||||
// Parse cookies from header first
|
||||
@ -16,29 +39,29 @@ export async function GET(request: Request) {
|
||||
})
|
||||
|
||||
// Try to get session token from cookies
|
||||
const sessionTokenFromHeader = cookieMap["__Secure-authjs.session-token"] || "NOT FOUND"
|
||||
const sessionTokenFromHeader = cookieMap[SESSION_COOKIE_NAME] || "NOT FOUND"
|
||||
|
||||
// Try to call auth() - this might fail or return null
|
||||
let session = null
|
||||
// Try to call auth() again for debugging (we already have session above, but this is for testing)
|
||||
let authError = null
|
||||
try {
|
||||
console.log("Debug endpoint: Calling auth()...")
|
||||
session = await auth()
|
||||
console.log("Debug endpoint: auth() returned", {
|
||||
// Already called above, but keeping for backward compatibility in response
|
||||
logger.debug("Debug endpoint: Session retrieved", {
|
||||
hasSession: !!session,
|
||||
sessionUser: session?.user,
|
||||
sessionKeys: session ? Object.keys(session) : []
|
||||
userId: session?.user?.id,
|
||||
userRole: session?.user?.role,
|
||||
})
|
||||
} catch (err) {
|
||||
authError = err instanceof Error ? err.message : String(err)
|
||||
console.error("Debug endpoint: auth() error", authError)
|
||||
logger.error("Debug endpoint: auth() error", {
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
})
|
||||
}
|
||||
|
||||
// Try to get cookie from Next.js cookie store
|
||||
let sessionTokenFromStore = "NOT ACCESSIBLE"
|
||||
try {
|
||||
const cookieStore = await cookies()
|
||||
sessionTokenFromStore = cookieStore.get("__Secure-authjs.session-token")?.value || "NOT FOUND"
|
||||
sessionTokenFromStore = cookieStore.get(SESSION_COOKIE_NAME)?.value || "NOT FOUND"
|
||||
} catch {
|
||||
// Cookie store might not be accessible in all contexts
|
||||
}
|
||||
|
||||
@ -3,6 +3,10 @@ import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { normalizeString } from "@/lib/utils"
|
||||
import { logActivity } from "@/lib/activity-log"
|
||||
import { logger } from "@/lib/logger"
|
||||
|
||||
// Mark this route as dynamic to prevent build-time data collection
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
@ -151,7 +155,9 @@ export async function POST(
|
||||
pointsChange
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error submitting guess:", error)
|
||||
logger.error("Error submitting guess", {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { unlink } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
// Mark this route as dynamic to prevent build-time data collection
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ photoId: string }> }
|
||||
@ -47,7 +51,10 @@ export async function DELETE(
|
||||
try {
|
||||
await unlink(filepath)
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file:", filepath, error)
|
||||
logger.error("Failed to delete file", {
|
||||
filepath,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
// Continue with database deletion even if file deletion fails
|
||||
}
|
||||
}
|
||||
@ -60,7 +67,9 @@ export async function DELETE(
|
||||
|
||||
return NextResponse.json({ success: true, message: "Photo deleted successfully" })
|
||||
} catch (error) {
|
||||
console.error("Error deleting photo:", error)
|
||||
logger.error("Error deleting photo", {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sendNewPhotoEmail } from "@/lib/email"
|
||||
|
||||
// Legacy endpoint for URL-based uploads (kept for backward compatibility)
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { url, answerName, points, maxAttempts } = await req.json()
|
||||
|
||||
if (!url || !answerName) {
|
||||
return NextResponse.json(
|
||||
{ error: "URL and answer name are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate points (must be positive integer, default to 1)
|
||||
const pointsValue = points ? Math.max(1, parseInt(points, 10)) : 1
|
||||
const maxAttemptsValue = maxAttempts && parseInt(maxAttempts, 10) > 0
|
||||
? parseInt(maxAttempts, 10)
|
||||
: null
|
||||
|
||||
// Check for duplicate URL
|
||||
const existingPhoto = await prisma.photo.findFirst({
|
||||
where: { url },
|
||||
})
|
||||
|
||||
if (existingPhoto) {
|
||||
return NextResponse.json(
|
||||
{ error: "This photo URL has already been uploaded (duplicate URL detected)" },
|
||||
{ status: 409 }
|
||||
)
|
||||
}
|
||||
|
||||
const photo = await prisma.photo.create({
|
||||
data: {
|
||||
uploaderId: session.user.id,
|
||||
url,
|
||||
answerName: answerName.trim(),
|
||||
points: pointsValue,
|
||||
maxAttempts: maxAttemptsValue,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
include: {
|
||||
uploader: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Send emails to all other users
|
||||
const allUsers = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { not: session.user.id },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Send emails asynchronously (don't wait for them)
|
||||
Promise.all(
|
||||
allUsers.map((user: { id: string; email: string; name: string }) =>
|
||||
sendNewPhotoEmail(user.email, user.name, photo.id, photo.uploader.name).catch(
|
||||
(err) => {
|
||||
console.error("Failed to send email to:", user.email, err)
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return NextResponse.json({ photo }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Error creating photo:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,39 @@
|
||||
/**
|
||||
* Multiple Photo Upload Endpoint
|
||||
*
|
||||
* POST /api/photos/upload-multiple
|
||||
*
|
||||
* Uploads multiple photos in a single request. Supports both file uploads and URL-based uploads.
|
||||
*
|
||||
* This endpoint is used by the upload page for batch uploads. It processes multiple photos
|
||||
* in parallel and sends email notifications for all successfully uploaded photos.
|
||||
*
|
||||
* Form Data:
|
||||
* - photo_{index}_file: File object (optional, if using file upload)
|
||||
* - photo_{index}_url: URL string (optional, if using URL upload)
|
||||
* - photo_{index}_answerName: Answer name (required)
|
||||
* - photo_{index}_points: Points value (optional, defaults to 1)
|
||||
* - photo_{index}_penaltyEnabled: "true" or "false" (optional)
|
||||
* - photo_{index}_penaltyPoints: Penalty points (optional)
|
||||
* - photo_{index}_maxAttempts: Maximum attempts (optional)
|
||||
* - count: Number of photos being uploaded
|
||||
*
|
||||
* Related endpoints:
|
||||
* - POST /api/photos/upload - Single photo upload (supports both file and URL)
|
||||
*/
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sendNewPhotoEmail } from "@/lib/email"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { writeFile } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { createHash } from "crypto"
|
||||
|
||||
// Mark this route as dynamic to prevent build-time data collection
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
@ -204,7 +231,11 @@ export async function POST(req: NextRequest) {
|
||||
photo.id,
|
||||
photo.uploader.name
|
||||
).catch((err) => {
|
||||
console.error("Failed to send email to:", user.email, "for photo:", photo.id, err)
|
||||
logger.error("Failed to send email", {
|
||||
email: user.email,
|
||||
photoId: photo.id,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
@ -216,7 +247,9 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Error uploading photos:", error)
|
||||
logger.error("Error uploading photos", {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@ -3,11 +3,15 @@ import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { sendNewPhotoEmail } from "@/lib/email"
|
||||
import { logActivity } from "@/lib/activity-log"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { writeFile } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { existsSync, mkdirSync } from "fs"
|
||||
import { createHash } from "crypto"
|
||||
|
||||
// Mark this route as dynamic to prevent build-time data collection
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
@ -86,7 +90,8 @@ export async function POST(req: NextRequest) {
|
||||
const uploadsDir = join(process.cwd(), "public", "uploads")
|
||||
if (!existsSync(uploadsDir)) {
|
||||
mkdirSync(uploadsDir, { recursive: true })
|
||||
console.log(`[UPLOAD] Created uploads directory: ${uploadsDir}`)
|
||||
// DEBUG level: directory creation is normal operation
|
||||
logger.debug("Created uploads directory", { path: uploadsDir })
|
||||
}
|
||||
console.log(`[UPLOAD] Using uploads directory: ${uploadsDir} (exists: ${existsSync(uploadsDir)})`)
|
||||
|
||||
@ -98,9 +103,14 @@ export async function POST(req: NextRequest) {
|
||||
const { access } = await import("fs/promises")
|
||||
try {
|
||||
await access(filepath)
|
||||
console.log(`[UPLOAD] File saved successfully: ${filepath}`)
|
||||
// DEBUG level: file save verification is normal operation
|
||||
logger.debug("File saved successfully", { filepath })
|
||||
} catch (error) {
|
||||
console.error(`[UPLOAD] File write verification failed: ${filepath}`, error)
|
||||
// ERROR level: file write failure is an error condition
|
||||
logger.error("File write verification failed", {
|
||||
filepath,
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
throw new Error("Failed to save file to disk")
|
||||
}
|
||||
|
||||
@ -166,7 +176,11 @@ export async function POST(req: NextRequest) {
|
||||
allUsers.map((user: { id: string; email: string; name: string }) =>
|
||||
sendNewPhotoEmail(user.email, user.name, photo.id, photo.uploader.name).catch(
|
||||
(err) => {
|
||||
console.error("Failed to send email to:", user.email, err)
|
||||
logger.error("Failed to send email", {
|
||||
email: user.email,
|
||||
photoId: photo.id,
|
||||
error: err instanceof Error ? err : new Error(String(err)),
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -189,7 +203,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
return NextResponse.json({ photo }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Error uploading photo:", error)
|
||||
logger.error("Error uploading photo", {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { auth } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/prisma"
|
||||
import { logger } from "@/lib/logger"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { hashPassword } from "@/lib/utils"
|
||||
|
||||
// Mark this route as dynamic to prevent build-time data collection
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth()
|
||||
@ -44,7 +48,9 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Error changing password:", error)
|
||||
logger.error("Error changing password", {
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { logger } from "@/lib/logger"
|
||||
import { readFile } from "fs/promises"
|
||||
import { join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
@ -7,8 +8,9 @@ export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
) {
|
||||
let filename: string | undefined
|
||||
try {
|
||||
const { filename } = await params
|
||||
filename = (await params).filename
|
||||
|
||||
// Sanitize filename - only allow alphanumeric, dots, hyphens
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(filename)) {
|
||||
@ -26,7 +28,11 @@ export async function GET(
|
||||
|
||||
// Check if file exists
|
||||
if (!existsSync(filepath)) {
|
||||
console.error(`[UPLOAD] File not found: ${filepath} (cwd: ${process.cwd()})`)
|
||||
logger.warn("File not found", {
|
||||
filepath,
|
||||
filename,
|
||||
cwd: process.cwd(),
|
||||
})
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
@ -49,7 +55,10 @@ export async function GET(
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[UPLOAD] Error serving file:", error)
|
||||
logger.error("Error serving file", {
|
||||
filename: filename || "unknown",
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
|
||||
@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Providers from "@/components/Providers";
|
||||
import Navigation from "@/components/Navigation";
|
||||
import HelpModal from "@/components/HelpModal";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@ -32,6 +33,7 @@ export default function RootLayout({
|
||||
<Providers>
|
||||
<Navigation />
|
||||
<main className="min-h-screen bg-gray-50 overflow-x-hidden">{children}</main>
|
||||
<HelpModal />
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -26,13 +26,21 @@ export default function LoginPage() {
|
||||
if (result?.error) {
|
||||
setError("Invalid email or password")
|
||||
} else if (result?.ok) {
|
||||
// Force a session refresh before redirect
|
||||
// This ensures the session cookie is properly set and read
|
||||
await fetch("/api/auth/session", { method: "GET" })
|
||||
|
||||
// Check if there's a callback URL in the query params
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const callbackUrl = params.get("callbackUrl") || "/photos"
|
||||
console.log("Redirecting to:", callbackUrl)
|
||||
// Use window.location.href to force a full page reload
|
||||
// This ensures the session cookie is read before middleware checks authentication
|
||||
window.location.href = callbackUrl
|
||||
|
||||
// Small delay to ensure cookie is set
|
||||
setTimeout(() => {
|
||||
// Use window.location.href to force a full page reload
|
||||
// This ensures the session cookie is read before middleware checks authentication
|
||||
window.location.href = callbackUrl
|
||||
}, 100)
|
||||
} else {
|
||||
setError("Login failed. Please try again.")
|
||||
}
|
||||
|
||||
@ -4,33 +4,34 @@ import { prisma } from "@/lib/prisma"
|
||||
import Link from "next/link"
|
||||
import PhotoThumbnail from "@/components/PhotoThumbnail"
|
||||
import DeletePhotoButton from "@/components/DeletePhotoButton"
|
||||
import { logger } from "@/lib/logger"
|
||||
|
||||
// Enable caching for this page
|
||||
export const revalidate = 60 // Revalidate every 60 seconds
|
||||
|
||||
export default async function PhotosPage() {
|
||||
console.log("PhotosPage: Starting, calling auth()...")
|
||||
// DEBUG level: only logs in development or when LOG_LEVEL=DEBUG
|
||||
logger.debug("PhotosPage: Starting, calling auth()")
|
||||
const session = await auth()
|
||||
|
||||
console.log("PhotosPage: auth() returned", {
|
||||
hasSession: !!session,
|
||||
sessionType: typeof session,
|
||||
sessionUser: session?.user,
|
||||
sessionKeys: session ? Object.keys(session) : [],
|
||||
sessionString: JSON.stringify(session, null, 2)
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
console.log("PhotosPage: No session, redirecting to login")
|
||||
logger.debug("PhotosPage: No session, redirecting to login")
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
if (!session.user) {
|
||||
console.log("PhotosPage: Session exists but no user, redirecting to login")
|
||||
// WARN level: session exists but no user is a warning condition
|
||||
logger.warn("PhotosPage: Session exists but no user, redirecting to login", {
|
||||
hasSession: !!session,
|
||||
sessionKeys: session ? Object.keys(session) : [],
|
||||
})
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
console.log("PhotosPage: Session valid, rendering page")
|
||||
logger.debug("PhotosPage: Session valid, rendering page", {
|
||||
userId: session.user.id,
|
||||
userEmail: session.user.email,
|
||||
})
|
||||
|
||||
// Limit to 50 photos per page for performance
|
||||
const photos = await prisma.photo.findMany({
|
||||
|
||||
285
components/HelpModal.tsx
Normal file
285
components/HelpModal.tsx
Normal file
@ -0,0 +1,285 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
export default function HelpModal() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Check for Shift+? (Shift+/ produces "?")
|
||||
// Also check for event.key === "?" as fallback for some keyboard layouts
|
||||
const isQuestionMark =
|
||||
(event.shiftKey && event.key === "/") ||
|
||||
event.key === "?" ||
|
||||
(event.code === "Slash" && event.shiftKey)
|
||||
|
||||
// Only trigger if Shift is pressed (not Ctrl/Cmd)
|
||||
if (event.shiftKey && isQuestionMark && !event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault()
|
||||
setIsOpen((prev) => !prev)
|
||||
}
|
||||
// Also close on Escape key
|
||||
if (event.key === "Escape" && isOpen) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto z-50"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-purple-600 to-indigo-600 text-white p-6 rounded-t-lg flex justify-between items-center">
|
||||
<h2 className="text-2xl font-bold">Welcome to MirrorMatch</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-purple-700 rounded-md transition"
|
||||
aria-label="Close help"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6 text-gray-700">
|
||||
{/* What is MirrorMatch */}
|
||||
<section>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 mr-2 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
What is MirrorMatch?
|
||||
</h3>
|
||||
<p className="text-gray-700 leading-relaxed">
|
||||
MirrorMatch is a fun and engaging photo guessing game where you can upload photos
|
||||
and challenge other players to guess who is in the picture. Test your knowledge of
|
||||
friends, family, or colleagues while competing for points on the leaderboard!
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* How to Play */}
|
||||
<section>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 mr-2 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
How to Play
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside space-y-2 text-gray-700">
|
||||
<li>
|
||||
<strong>Upload Photos:</strong> Go to the Upload page and add photos with answer
|
||||
names. You can upload files or use image URLs.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Guess Photos:</strong> Browse the Photos page and click on any photo to
|
||||
make your guess. Enter the name of the person you think is in the picture.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Earn Points:</strong> Get points for each correct guess! The more you
|
||||
guess correctly, the higher you'll climb on the leaderboard.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Compete:</strong> Check the Leaderboard to see how you rank against other
|
||||
players.
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 mr-2 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"
|
||||
/>
|
||||
</svg>
|
||||
Key Features
|
||||
</h3>
|
||||
<ul className="space-y-2 text-gray-700">
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">•</span>
|
||||
<span>
|
||||
<strong>Photo Upload:</strong> Share photos via file upload or URL
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">•</span>
|
||||
<span>
|
||||
<strong>Guessing System:</strong> Submit guesses and get instant feedback
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">•</span>
|
||||
<span>
|
||||
<strong>Email Notifications:</strong> Get notified when new photos are
|
||||
uploaded
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">•</span>
|
||||
<span>
|
||||
<strong>Leaderboard:</strong> Track rankings and compete with others
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">•</span>
|
||||
<span>
|
||||
<strong>Profile Management:</strong> View your points and manage your account
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<section>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 mr-2 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
Keyboard Shortcuts
|
||||
</h3>
|
||||
<div className="bg-gray-50 rounded-md p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700">
|
||||
<kbd className="px-2 py-1 bg-white border border-gray-300 rounded text-sm font-mono">
|
||||
Shift
|
||||
</kbd>
|
||||
{" + "}
|
||||
<kbd className="px-2 py-1 bg-white border border-gray-300 rounded text-sm font-mono">
|
||||
?
|
||||
</kbd>
|
||||
</span>
|
||||
<span className="text-gray-600">Open/Close this help window</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-700">
|
||||
<kbd className="px-2 py-1 bg-white border border-gray-300 rounded text-sm font-mono">
|
||||
Esc
|
||||
</kbd>
|
||||
</span>
|
||||
<span className="text-gray-600">Close help window</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tips */}
|
||||
<section>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3 flex items-center">
|
||||
<svg
|
||||
className="h-5 w-5 mr-2 text-purple-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
</svg>
|
||||
Tips
|
||||
</h3>
|
||||
<ul className="space-y-2 text-gray-700">
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">💡</span>
|
||||
<span>Guesses are case-insensitive, so don't worry about capitalization</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">💡</span>
|
||||
<span>You can't guess your own photos, but you can still view them</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">💡</span>
|
||||
<span>
|
||||
You'll receive email notifications when other users upload new photos
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-purple-600 mr-2">💡</span>
|
||||
<span>Check the leaderboard regularly to see your ranking</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-gray-50 p-4 rounded-b-lg border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
Press <kbd className="px-2 py-1 bg-white border border-gray-300 rounded text-xs font-mono">Esc</kbd> or click outside to close
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
45
deploy-and-watch.sh
Executable file
45
deploy-and-watch.sh
Executable file
@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Deploy server and watch activity logs
|
||||
# Usage: ./deploy-and-watch.sh
|
||||
|
||||
echo "========================================="
|
||||
echo "Deploying and watching activity logs"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Start rebuild in background
|
||||
echo "Starting server deployment..."
|
||||
./rebuild.sh prod &
|
||||
REBUILD_PID=$!
|
||||
|
||||
# Wait for log file to be created (with timeout)
|
||||
LOG_FILE="/tmp/mirrormatch-server.log"
|
||||
MAX_WAIT=30
|
||||
WAITED=0
|
||||
|
||||
echo "Waiting for server to start and create log file..."
|
||||
while [ ! -f "$LOG_FILE" ] && [ $WAITED -lt $MAX_WAIT ]; do
|
||||
sleep 1
|
||||
WAITED=$((WAITED + 1))
|
||||
if [ $((WAITED % 5)) -eq 0 ]; then
|
||||
echo " Still waiting... ($WAITED/$MAX_WAIT seconds)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo "⚠ Warning: Log file not created after $MAX_WAIT seconds"
|
||||
echo " Check if rebuild.sh completed successfully"
|
||||
echo " You can manually watch logs later: ./watch-activity.sh $LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Log file created: $LOG_FILE"
|
||||
echo ""
|
||||
echo "========================================="
|
||||
echo "Watching activity logs..."
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
# Watch activity logs
|
||||
./watch-activity.sh "$LOG_FILE"
|
||||
@ -15,3 +15,9 @@ SMTP_FROM="MirrorMatch <noreply@mirrormatch.com>"
|
||||
|
||||
# In development, emails will be logged to console or use Ethereal
|
||||
# No SMTP config needed for dev mode
|
||||
|
||||
# Logging Configuration
|
||||
# LOG_LEVEL: DEBUG, INFO, WARN, ERROR, or NONE (default: DEBUG in dev, INFO in production)
|
||||
# LOG_FORMAT: "json" for structured JSON logs, or omit for human-readable format
|
||||
# LOG_LEVEL=INFO
|
||||
# LOG_FORMAT=json
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Activity logging utility for tracking user actions
|
||||
* Uses structured logging with log levels for better production control
|
||||
*/
|
||||
|
||||
import { logger } from './logger'
|
||||
|
||||
export interface ActivityLog {
|
||||
timestamp: string
|
||||
userId?: string
|
||||
@ -14,6 +17,18 @@ export interface ActivityLog {
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Log user activity with structured data
|
||||
* Uses INFO level logging - can be filtered via LOG_LEVEL environment variable
|
||||
*
|
||||
* @param action - The action being performed (e.g., "PHOTO_UPLOAD", "GUESS_SUBMITTED")
|
||||
* @param path - The request path
|
||||
* @param method - The HTTP method
|
||||
* @param user - Optional user object (id, email, role)
|
||||
* @param details - Optional additional context data
|
||||
* @param request - Optional Request object for extracting IP address
|
||||
* @returns Structured activity log object
|
||||
*/
|
||||
export function logActivity(
|
||||
action: string,
|
||||
path: string,
|
||||
@ -21,7 +36,7 @@ export function logActivity(
|
||||
user?: { id: string; email: string; role: string } | null,
|
||||
details?: Record<string, unknown>,
|
||||
request?: Request
|
||||
) {
|
||||
): ActivityLog {
|
||||
const timestamp = new Date().toISOString()
|
||||
const ip = request?.headers.get("x-forwarded-for") ||
|
||||
request?.headers.get("x-real-ip") ||
|
||||
@ -39,14 +54,17 @@ export function logActivity(
|
||||
details
|
||||
}
|
||||
|
||||
// Format: [ACTION] timestamp | method path | User: email (role) | IP: ip | Details: {...}
|
||||
const userInfo = user
|
||||
? `${user.email} (${user.role})`
|
||||
: "UNAUTHENTICATED"
|
||||
|
||||
const detailsStr = details ? ` | Details: ${JSON.stringify(details)}` : ""
|
||||
|
||||
console.log(`[${action}] ${timestamp} | ${method} ${path} | User: ${userInfo} | IP: ${ip.split(",")[0].trim()}${detailsStr}`)
|
||||
// Use structured logging with INFO level
|
||||
// This allows filtering via LOG_LEVEL environment variable
|
||||
logger.info(`Activity: ${action}`, {
|
||||
method,
|
||||
path,
|
||||
userId: user?.id,
|
||||
userEmail: user?.email,
|
||||
userRole: user?.role,
|
||||
ip: ip.split(",")[0].trim(),
|
||||
...(details && { details }),
|
||||
})
|
||||
|
||||
return log
|
||||
}
|
||||
|
||||
102
lib/auth.ts
102
lib/auth.ts
@ -2,15 +2,29 @@ import NextAuth from "next-auth"
|
||||
import Credentials from "next-auth/providers/credentials"
|
||||
import { prisma } from "./prisma"
|
||||
import bcrypt from "bcryptjs"
|
||||
import { logger } from "./logger"
|
||||
|
||||
const nextAuthSecret = process.env.NEXTAUTH_SECRET
|
||||
if (!nextAuthSecret) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set. Define it to enable authentication.")
|
||||
// Lazy check for NEXTAUTH_SECRET - only validate when actually needed
|
||||
// This prevents build-time errors when the secret isn't available
|
||||
function getNextAuthSecret(): string {
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
if (!secret) {
|
||||
// Always throw at runtime - this is a critical configuration error
|
||||
throw new Error("NEXTAUTH_SECRET is not set. Define it to enable authentication.")
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
// Determine if we should use secure cookies based on AUTH_URL/NEXTAUTH_URL
|
||||
// Auth.js v5 derives this from the origin it detects, so we need to be explicit
|
||||
const authUrl = process.env.AUTH_URL || process.env.NEXTAUTH_URL || "http://localhost:3000"
|
||||
const isHttp = authUrl.startsWith("http://")
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
// trustHost must be true for NextAuth v5 to work, even on localhost
|
||||
trustHost: true,
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
basePath: "/api/auth",
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "Credentials",
|
||||
@ -48,7 +62,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
role: user.role,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Auth authorize error:", err)
|
||||
logger.error("Auth authorize error", err instanceof Error ? err : new Error(String(err)))
|
||||
return null
|
||||
}
|
||||
}
|
||||
@ -61,27 +75,22 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
token.role = (user as { role: string }).role
|
||||
token.email = user.email
|
||||
token.name = user.name
|
||||
console.log("JWT callback: user added to token", { userId: user.id, email: user.email })
|
||||
// DEBUG level: only logs in development or when LOG_LEVEL=DEBUG
|
||||
logger.debug("JWT callback: user added to token", {
|
||||
userId: user.id,
|
||||
email: user.email
|
||||
})
|
||||
} else {
|
||||
console.log("JWT callback: no user, token exists", {
|
||||
// DEBUG level: token refresh (normal operation, only log in debug mode)
|
||||
logger.debug("JWT callback: token refresh", {
|
||||
hasToken: !!token,
|
||||
tokenKeys: token ? Object.keys(token) : [],
|
||||
tokenId: token?.id,
|
||||
tokenEmail: token?.email,
|
||||
tokenName: token?.name,
|
||||
tokenRole: token?.role
|
||||
})
|
||||
}
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
console.log("Session callback: called", {
|
||||
hasToken: !!token,
|
||||
hasSession: !!session,
|
||||
tokenId: token?.id,
|
||||
tokenEmail: token?.email,
|
||||
stackTrace: new Error().stack?.split('\n').slice(1, 4).join('\n')
|
||||
})
|
||||
// Always ensure session.user exists when token exists
|
||||
if (token && (token.id || token.email)) {
|
||||
session.user = {
|
||||
@ -91,23 +100,17 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
name: (token.name as string) || session.user?.name || "",
|
||||
role: token.role as string,
|
||||
}
|
||||
console.log("Session callback: session created", {
|
||||
// DEBUG level: session creation is normal operation, only log in debug mode
|
||||
logger.debug("Session callback: session created", {
|
||||
userId: token.id,
|
||||
email: token.email,
|
||||
hasUser: !!session.user,
|
||||
userKeys: session.user ? Object.keys(session.user) : [],
|
||||
userRole: token.role,
|
||||
sessionUser: session.user,
|
||||
sessionExpires: session.expires,
|
||||
fullSession: JSON.stringify(session, null, 2)
|
||||
})
|
||||
} else {
|
||||
console.warn("Session callback: token missing or invalid", {
|
||||
// WARN level: token missing/invalid is a warning condition
|
||||
logger.warn("Session callback: token missing or invalid", {
|
||||
hasToken: !!token,
|
||||
tokenKeys: token ? Object.keys(token) : [],
|
||||
hasSession: !!session,
|
||||
sessionKeys: session ? Object.keys(session) : [],
|
||||
sessionUser: session?.user,
|
||||
tokenId: token?.id,
|
||||
tokenEmail: token?.email
|
||||
})
|
||||
@ -124,16 +127,39 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
strategy: "jwt",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
},
|
||||
cookies: {
|
||||
sessionToken: {
|
||||
name: `__Secure-authjs.session-token`,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: true, // Always secure in production (HTTPS required)
|
||||
},
|
||||
},
|
||||
},
|
||||
secret: nextAuthSecret,
|
||||
// Explicitly configure cookies for HTTP (localhost)
|
||||
// For HTTPS, let Auth.js defaults handle it (prefixes + Secure)
|
||||
cookies: isHttp
|
||||
? {
|
||||
// localhost / pure HTTP: no prefixes, no Secure
|
||||
sessionToken: {
|
||||
name: "authjs.session-token",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
csrfToken: {
|
||||
name: "authjs.csrf-token",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
callbackUrl: {
|
||||
name: "authjs.callback-url",
|
||||
options: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined, // Let Auth.js defaults handle HTTPS envs (prefixes + Secure)
|
||||
secret: getNextAuthSecret(),
|
||||
})
|
||||
|
||||
10
lib/constants.ts
Normal file
10
lib/constants.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Application-wide constants
|
||||
*/
|
||||
|
||||
/**
|
||||
* NextAuth session cookie name
|
||||
* Must match the cookie name defined in lib/auth.ts
|
||||
* For HTTP (localhost), no prefix. For HTTPS, Auth.js will add __Secure- prefix automatically.
|
||||
*/
|
||||
export const SESSION_COOKIE_NAME = "authjs.session-token"
|
||||
156
lib/logger.ts
Normal file
156
lib/logger.ts
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Structured logging utility with log levels and environment-based filtering
|
||||
*
|
||||
* Log levels (in order of severity):
|
||||
* - DEBUG: Detailed information for debugging (only in development)
|
||||
* - INFO: General informational messages
|
||||
* - WARN: Warning messages for potentially harmful situations
|
||||
* - ERROR: Error messages for error events
|
||||
*
|
||||
* Usage:
|
||||
* import { logger } from '@/lib/logger'
|
||||
* logger.debug('Debug message', { data })
|
||||
* logger.info('Info message', { data })
|
||||
* logger.warn('Warning message', { data })
|
||||
* logger.error('Error message', { error })
|
||||
*/
|
||||
|
||||
export enum LogLevel {
|
||||
DEBUG = 0,
|
||||
INFO = 1,
|
||||
WARN = 2,
|
||||
ERROR = 3,
|
||||
NONE = 4, // Disable all logging
|
||||
}
|
||||
|
||||
export interface LogContext {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(message: string, context?: LogContext): void;
|
||||
info(message: string, context?: LogContext): void;
|
||||
warn(message: string, context?: LogContext): void;
|
||||
error(message: string, context?: LogContext | Error): void;
|
||||
isLevelEnabled(level: LogLevel): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse log level from environment variable or default based on NODE_ENV
|
||||
*/
|
||||
function getLogLevel(): LogLevel {
|
||||
const envLogLevel = process.env.LOG_LEVEL?.toUpperCase();
|
||||
|
||||
// If explicitly set, use that
|
||||
if (envLogLevel) {
|
||||
switch (envLogLevel) {
|
||||
case 'DEBUG':
|
||||
return LogLevel.DEBUG;
|
||||
case 'INFO':
|
||||
return LogLevel.INFO;
|
||||
case 'WARN':
|
||||
return LogLevel.WARN;
|
||||
case 'ERROR':
|
||||
return LogLevel.ERROR;
|
||||
case 'NONE':
|
||||
return LogLevel.NONE;
|
||||
default:
|
||||
// Invalid value, fall through to default behavior
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Default behavior: DEBUG in development, INFO in production
|
||||
return process.env.NODE_ENV === 'production' ? LogLevel.INFO : LogLevel.DEBUG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format log entry as structured JSON or human-readable string
|
||||
*/
|
||||
function formatLog(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: LogContext | Error
|
||||
): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const levelName = LogLevel[level];
|
||||
|
||||
// If structured logging is enabled, output JSON
|
||||
if (process.env.LOG_FORMAT === 'json') {
|
||||
const logEntry: Record<string, unknown> = {
|
||||
timestamp,
|
||||
level: levelName,
|
||||
message,
|
||||
};
|
||||
|
||||
if (context) {
|
||||
if (context instanceof Error) {
|
||||
logEntry.error = {
|
||||
name: context.name,
|
||||
message: context.message,
|
||||
stack: context.stack,
|
||||
};
|
||||
} else {
|
||||
Object.assign(logEntry, context);
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(logEntry);
|
||||
}
|
||||
|
||||
// Human-readable format
|
||||
const contextStr = context
|
||||
? context instanceof Error
|
||||
? ` | Error: ${context.name}: ${context.message}`
|
||||
: ` | ${JSON.stringify(context)}`
|
||||
: '';
|
||||
|
||||
return `[${levelName}] ${timestamp} | ${message}${contextStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a logger instance with the configured log level
|
||||
*/
|
||||
function createLogger(): Logger {
|
||||
const currentLevel = getLogLevel();
|
||||
|
||||
const shouldLog = (level: LogLevel): boolean => {
|
||||
return level >= currentLevel;
|
||||
};
|
||||
|
||||
return {
|
||||
debug(message: string, context?: LogContext): void {
|
||||
if (shouldLog(LogLevel.DEBUG)) {
|
||||
console.log(formatLog(LogLevel.DEBUG, message, context));
|
||||
}
|
||||
},
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
if (shouldLog(LogLevel.INFO)) {
|
||||
console.log(formatLog(LogLevel.INFO, message, context));
|
||||
}
|
||||
},
|
||||
|
||||
warn(message: string, context?: LogContext): void {
|
||||
if (shouldLog(LogLevel.WARN)) {
|
||||
console.warn(formatLog(LogLevel.WARN, message, context));
|
||||
}
|
||||
},
|
||||
|
||||
error(message: string, context?: LogContext | Error): void {
|
||||
if (shouldLog(LogLevel.ERROR)) {
|
||||
console.error(formatLog(LogLevel.ERROR, message, context));
|
||||
}
|
||||
},
|
||||
|
||||
isLevelEnabled(level: LogLevel): boolean {
|
||||
return shouldLog(level);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Export singleton logger instance
|
||||
export const logger = createLogger();
|
||||
|
||||
// Export for testing
|
||||
export { getLogLevel, formatLog, createLogger };
|
||||
51
merge-main-into-dev.sh
Executable file
51
merge-main-into-dev.sh
Executable file
@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Merge main into dev to resolve conflicts before merging dev into main
|
||||
|
||||
set -e
|
||||
|
||||
echo "========================================="
|
||||
echo "Merging main into dev to resolve conflicts"
|
||||
echo "========================================="
|
||||
|
||||
cd /home/beast/Code/mirrormatch
|
||||
|
||||
# 1. Make sure we're on dev
|
||||
echo ""
|
||||
echo "Step 1: Checking out dev branch..."
|
||||
git checkout dev
|
||||
git pull gitea dev
|
||||
|
||||
# 2. Fetch latest main
|
||||
echo ""
|
||||
echo "Step 2: Fetching latest main..."
|
||||
git fetch gitea main
|
||||
|
||||
# 3. Try to merge main into dev
|
||||
echo ""
|
||||
echo "Step 3: Merging main into dev..."
|
||||
echo "This may show conflicts that need to be resolved manually."
|
||||
echo ""
|
||||
|
||||
if git merge gitea/main --no-commit; then
|
||||
echo "✓ No conflicts! Merging..."
|
||||
git commit -m "Merge main into dev: resolve conflicts before MR"
|
||||
echo ""
|
||||
echo "✓ Merge complete! Now push dev:"
|
||||
echo " git push gitea dev"
|
||||
echo ""
|
||||
echo "Then your MR from dev → main should work without conflicts."
|
||||
else
|
||||
echo ""
|
||||
echo "⚠ Conflicts detected! You need to resolve them manually:"
|
||||
echo ""
|
||||
echo "Conflicting files:"
|
||||
git diff --name-only --diff-filter=U
|
||||
echo ""
|
||||
echo "To resolve:"
|
||||
echo " 1. Edit the conflicting files"
|
||||
echo " 2. git add <resolved-files>"
|
||||
echo " 3. git commit -m 'Merge main into dev: resolve conflicts'"
|
||||
echo " 4. git push gitea dev"
|
||||
echo ""
|
||||
echo "Then your MR from dev → main should work."
|
||||
fi
|
||||
@ -1,5 +1,31 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
/**
|
||||
* Next.js Configuration
|
||||
*
|
||||
* Configuration decisions:
|
||||
*
|
||||
* 1. Image Optimization:
|
||||
* - Enabled (unoptimized: false) for better performance and bandwidth usage
|
||||
* - Remote patterns allow all hosts (http/https) - this is permissive but necessary
|
||||
* for the photo guessing game where users may upload images from any URL.
|
||||
* - Consider restricting in production if security is a concern, but this would
|
||||
* limit functionality for URL-based photo uploads.
|
||||
*
|
||||
* 2. Turbopack Configuration:
|
||||
* - Currently configured but not actively used (dev script uses --webpack flag)
|
||||
* - Kept for future migration to Turbopack when stable
|
||||
* - Can be removed if not planning to use Turbopack
|
||||
*
|
||||
* 3. Webpack Configuration:
|
||||
* - Prisma client is externalized on server-side to prevent bundling issues
|
||||
* - This is necessary because Prisma generates client code that shouldn't be bundled
|
||||
* - Required for proper Prisma functionality in Next.js
|
||||
*
|
||||
* 4. Page Extensions:
|
||||
* - Only processes TypeScript/JavaScript files (ts, tsx, js, jsx)
|
||||
* - Prevents processing of other file types as pages
|
||||
*/
|
||||
const nextConfig: NextConfig = {
|
||||
// Only process specific file extensions
|
||||
pageExtensions: ["ts", "tsx", "js", "jsx"],
|
||||
@ -16,10 +42,15 @@ const nextConfig: NextConfig = {
|
||||
hostname: "**",
|
||||
},
|
||||
],
|
||||
unoptimized: false, // Enable optimization for better performance
|
||||
// Enable optimization for better performance and bandwidth usage
|
||||
// Note: Remote patterns are permissive to allow URL-based photo uploads
|
||||
// Consider restricting in production if security is a concern
|
||||
unoptimized: false,
|
||||
},
|
||||
|
||||
// Configure Turbopack
|
||||
// Configure Turbopack (currently not used - dev script uses --webpack)
|
||||
// Kept for future migration when Turbopack is stable
|
||||
// Can be removed if not planning to use Turbopack
|
||||
turbopack: {
|
||||
resolveExtensions: [
|
||||
".tsx",
|
||||
@ -38,6 +69,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
|
||||
// Webpack configuration to externalize Prisma
|
||||
// Required: Prisma client must be externalized on server-side to prevent bundling issues
|
||||
webpack: (config, { isServer }) => {
|
||||
if (isServer) {
|
||||
config.externals = config.externals || [];
|
||||
|
||||
42
proxy.ts
42
proxy.ts
@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import { getToken } from "next-auth/jwt"
|
||||
import { logActivity } from "./lib/activity-log"
|
||||
|
||||
export async function proxy(request: NextRequest) {
|
||||
const pathname = request.nextUrl.pathname
|
||||
@ -11,30 +12,39 @@ export async function proxy(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Get token (works in Edge runtime)
|
||||
// Explicitly specify the cookie name to match NextAuth config
|
||||
const cookieName = "__Secure-authjs.session-token"
|
||||
// For HTTPS, NextAuth adds __Secure- prefix automatically
|
||||
// getToken should handle the prefix, but we specify the base name
|
||||
const isHttps = request.url.startsWith("https://")
|
||||
const cookieName = isHttps ? `__Secure-authjs.session-token` : `authjs.session-token`
|
||||
|
||||
const token = await getToken({
|
||||
req: request,
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
cookieName: cookieName
|
||||
cookieName: cookieName,
|
||||
})
|
||||
|
||||
// User activity logging - track all page visits and API calls
|
||||
const timestamp = new Date().toISOString()
|
||||
const userAgent = request.headers.get("user-agent") || "unknown"
|
||||
const ip = request.headers.get("x-forwarded-for") ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"unknown"
|
||||
// Uses structured logging with log levels (INFO level, can be filtered)
|
||||
const user = token ? {
|
||||
id: token.id as string,
|
||||
email: token.email as string,
|
||||
role: token.role as string,
|
||||
} : null
|
||||
|
||||
const referer = request.headers.get("referer") || "direct"
|
||||
const method = request.method
|
||||
const userAgent = request.headers.get("user-agent") || "unknown"
|
||||
|
||||
if (token) {
|
||||
// Log authenticated user activity
|
||||
console.log(`[ACTIVITY] ${timestamp} | ${method} ${pathname} | User: ${token.email} (${token.role}) | IP: ${ip} | Referer: ${referer}`)
|
||||
} else {
|
||||
// Log unauthenticated access attempts
|
||||
console.log(`[ACTIVITY] ${timestamp} | ${method} ${pathname} | User: UNAUTHENTICATED | IP: ${ip} | Referer: ${referer} | UA: ${userAgent.substring(0, 100)}`)
|
||||
}
|
||||
logActivity(
|
||||
token ? "PAGE_VIEW" : "UNAUTHENTICATED_ACCESS",
|
||||
pathname,
|
||||
request.method,
|
||||
user,
|
||||
{
|
||||
referer,
|
||||
userAgent: userAgent.substring(0, 100), // Limit length
|
||||
},
|
||||
request
|
||||
)
|
||||
|
||||
// Protected routes - require authentication
|
||||
if (!token) {
|
||||
|
||||
96
rebuild.sh
Executable file
96
rebuild.sh
Executable file
@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
# Complete clean rebuild and start script for MirrorMatch
|
||||
# Usage: ./rebuild.sh [dev|prod]
|
||||
# dev - Development mode (hot reload, foreground)
|
||||
# prod - Production mode (optimized, background with logging)
|
||||
|
||||
set -e
|
||||
|
||||
# Get the directory where this script is located
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR" || {
|
||||
echo "Error: Could not change to script directory: $SCRIPT_DIR"
|
||||
exit 1
|
||||
}
|
||||
|
||||
MODE=${1:-prod}
|
||||
|
||||
echo "========================================="
|
||||
echo "MirrorMatch - Clean Rebuild & Start"
|
||||
echo "Mode: ${MODE}"
|
||||
echo "Working directory: $SCRIPT_DIR"
|
||||
echo "========================================="
|
||||
|
||||
# Step 1: Kill everything
|
||||
echo ""
|
||||
echo "Step 1: Killing all processes..."
|
||||
sudo fuser -k 3000/tcp 2>/dev/null || true
|
||||
killall -9 node 2>/dev/null || true
|
||||
pkill -f "next" 2>/dev/null || true
|
||||
sleep 2
|
||||
echo "✓ All processes killed"
|
||||
|
||||
# Step 2: Free ports
|
||||
echo ""
|
||||
echo "Step 2: Freeing ports..."
|
||||
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
|
||||
lsof -ti:3003 | xargs kill -9 2>/dev/null || true
|
||||
sleep 1
|
||||
echo "✓ Ports freed"
|
||||
|
||||
# Step 3: Clean build artifacts
|
||||
echo ""
|
||||
echo "Step 3: Cleaning build artifacts..."
|
||||
rm -rf .next node_modules/.cache .next/cache .next/dev/lock 2>/dev/null || true
|
||||
echo "✓ Build artifacts cleaned"
|
||||
|
||||
# Step 4: Rebuild (only for production)
|
||||
if [ "$MODE" = "prod" ]; then
|
||||
echo ""
|
||||
echo "Step 4: Rebuilding application..."
|
||||
npm run build
|
||||
echo "✓ Build complete"
|
||||
fi
|
||||
|
||||
# Step 5: Start server
|
||||
echo ""
|
||||
echo "Step 5: Starting server..."
|
||||
echo "========================================="
|
||||
|
||||
if [ "$MODE" = "dev" ]; then
|
||||
echo "Development mode - logs will appear below:"
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
export NODE_ENV=development
|
||||
unset AUTH_TRUST_HOST
|
||||
npm run dev
|
||||
else
|
||||
LOG_FILE="${LOG_FILE:-/tmp/mirrormatch-server.log}"
|
||||
echo "Production mode - server running in background"
|
||||
echo "View logs: tail -f $LOG_FILE"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
export NODE_ENV=production
|
||||
unset AUTH_TRUST_HOST
|
||||
npm run start > "$LOG_FILE" 2>&1 &
|
||||
SERVER_PID=$!
|
||||
echo "Server PID: $SERVER_PID"
|
||||
echo "Log file: $LOG_FILE"
|
||||
echo ""
|
||||
|
||||
# Wait for server to start (check log file for "Ready" message or check HTTP)
|
||||
echo "Waiting for server to start..."
|
||||
for i in {1..30}; do
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null | grep -q "200\|307\|404"; then
|
||||
echo "✓ Server is running on http://localhost:3000"
|
||||
break
|
||||
fi
|
||||
if [ $i -eq 30 ]; then
|
||||
echo "⚠ Server may still be starting. Check logs: tail -f $LOG_FILE"
|
||||
echo " Or check if process is running: ps -p $SERVER_PID"
|
||||
else
|
||||
sleep 1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
82
watch-activity.sh
Normal file → Executable file
82
watch-activity.sh
Normal file → Executable file
@ -1,10 +1,92 @@
|
||||
#!/bin/bash
|
||||
# Watch user activity logs in real-time
|
||||
<<<<<<< HEAD
|
||||
#
|
||||
# This script monitors logs for MirrorMatch activity.
|
||||
# It can watch systemd journal logs OR application log files.
|
||||
#
|
||||
# Usage: ./watch-activity.sh [logfile]
|
||||
# - No argument: Try systemd journal (requires systemd service)
|
||||
# - With argument: Watch specified log file (e.g., ./watch-activity.sh /tmp/mirrormatch-server.log)
|
||||
#
|
||||
# For local development:
|
||||
# - If running via rebuild.sh: ./watch-activity.sh /tmp/mirrormatch-server.log
|
||||
# - If running via npm run dev: ./watch-activity.sh app.log (if you redirect output)
|
||||
# - If running as systemd service: ./watch-activity.sh (uses journalctl)
|
||||
|
||||
LOG_FILE="${1:-}"
|
||||
=======
|
||||
# Usage: ./watch-activity.sh
|
||||
>>>>>>> gitea/main
|
||||
|
||||
echo "Watching user activity logs..."
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
<<<<<<< HEAD
|
||||
if [ -n "$LOG_FILE" ]; then
|
||||
# Watch log file
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo "⚠ Warning: Log file '$LOG_FILE' does not exist yet."
|
||||
echo " Waiting for it to be created (will wait up to 30 seconds)..."
|
||||
echo ""
|
||||
# Wait for file to be created
|
||||
for i in {1..30}; do
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
echo "✓ Log file created"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo "❌ Log file still doesn't exist after 30 seconds"
|
||||
echo " Make sure the server is running: ./rebuild.sh prod"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
tail -f "$LOG_FILE" 2>/dev/null | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]|Activity:|INFO.*Activity:" || {
|
||||
echo "⚠ No activity logs found. Make sure:"
|
||||
echo " 1. Server is running"
|
||||
echo " 2. LOG_LEVEL is set to INFO or DEBUG"
|
||||
echo " 3. Users are actually using the site"
|
||||
}
|
||||
elif systemctl is-active --quiet app-backend 2>/dev/null; then
|
||||
# Use systemd journal if service is active
|
||||
echo "Using systemd journal (app-backend service)"
|
||||
echo ""
|
||||
sudo journalctl -u app-backend -f | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]|Activity:|INFO.*Activity:"
|
||||
else
|
||||
# Try common log file locations
|
||||
echo "Systemd service not found. Trying common log file locations..."
|
||||
echo ""
|
||||
|
||||
LOG_FILES=(
|
||||
"/tmp/mirrormatch-server.log"
|
||||
"app.log"
|
||||
"./app.log"
|
||||
"/var/log/mirrormatch/app.log"
|
||||
)
|
||||
|
||||
FOUND=false
|
||||
for log in "${LOG_FILES[@]}"; do
|
||||
if [ -f "$log" ]; then
|
||||
echo "Found log file: $log"
|
||||
echo ""
|
||||
tail -f "$log" | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]|Activity:|INFO.*Activity:"
|
||||
FOUND=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FOUND" = false ]; then
|
||||
echo "❌ No log file found. Options:"
|
||||
echo " 1. Specify log file: ./watch-activity.sh /path/to/logfile"
|
||||
echo " 2. If using rebuild.sh, logs go to: /tmp/mirrormatch-server.log"
|
||||
echo " 3. If using systemd, ensure app-backend service is running"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
=======
|
||||
# Watch for activity logs (ACTIVITY, PHOTO_UPLOAD, GUESS_SUBMIT)
|
||||
sudo journalctl -u app-backend -f | grep -E "\[ACTIVITY\]|\[PHOTO_UPLOAD\]|\[GUESS_SUBMIT\]"
|
||||
>>>>>>> gitea/main
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user