diff --git a/.gitignore b/.gitignore index 59a415f..a96ebf7 100644 --- a/.gitignore +++ b/.gitignore @@ -112,4 +112,5 @@ android/local.properties *.pid docker-data/ .obsidian -.excalidraw \ No newline at end of file +.excalidraw +.gocache \ No newline at end of file diff --git a/API_INTEGRATION.md b/API_INTEGRATION.md deleted file mode 100644 index 73b3b6d..0000000 --- a/API_INTEGRATION.md +++ /dev/null @@ -1,257 +0,0 @@ -# API Integration & Docker Setup Guide - -## Overview - -This guide covers the API integration between the Go backend server and Next.js frontend, with Docker support for running both services independently or together. - -## Architecture - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Docker Network │ -├──────────────────────┬──────────────────┬────────────────────┤ -│ PostgreSQL │ Backend API │ Frontend Web │ -│ (Port 5432) │ (Port 8080) │ (Port 3000) │ -└──────────────────────┴──────────────────┴────────────────────┘ -``` - -## Running Services - -### Local Development (Without Docker) - -1. **Start PostgreSQL locally** (port 5432) - - Ensure PostgreSQL is running - - Create database `coderz` with user `coderz-space` - -2. **Start Backend Server** - ```bash - cd apps/server - go mod tidy - go run cmd/main.go - # API runs on http://localhost:8080/api - ``` - -3. **Start Frontend Web App** (in another terminal) - ```bash - cd apps/web - npm install - npm run dev - # Web app runs on http://localhost:3000 - ``` - -4. **Environment Configuration** - - Backend uses `apps/server/.env` - - Frontend uses `apps/web/.env.local` (for local dev) - - See `.env.example` files for reference - -### Docker Deployment - -#### Spin up entire stack (Web + API + Database): -```bash -docker-compose up --build -``` - -This will: -- Start PostgreSQL on port 5432 -- Run migrations automatically -- Start API on port 8080 -- Start Web on port 3000 - -#### Run services separately: - -**Backend only:** -```bash -docker-compose up --build api postgres migrate -``` - -**Frontend only (requires external API):** -```bash -docker build -t coderz-web apps/web -docker run -p 3000:3000 \ - -e NEXT_PUBLIC_API_URL=http://host.docker.internal:8080/api \ - coderz-web -``` - -## API Integration Architecture - -### Client-Side Security (Frontend) - -**File:** `apps/web/services/api.ts` - -Features: -- ✅ Centralized HTTP client using Axios -- ✅ Automatic auth token injection from localStorage -- ✅ Request timeouts (10 seconds) -- ✅ CORS credentials enabled -- ✅ X-Requested-With header for CSRF protection -- ✅ Automatic token refresh on 401 responses -- ✅ Custom error handling via `APIError` class -- ✅ Server-side rendering safe (no direct DOM access) - -### Authentication Flow - -1. **Login Request** - - POST `/api/auth/mentee/login` with credentials - - Backend responds with `{ token, refreshToken, mentee }` - -2. **Token Storage** - - Access token stored in `localStorage` (client-side) - - Used automatically in `Authorization: Bearer ` header - - Cleared on 401 response - -3. **Protected Endpoints** - - All subsequent requests include auth header - - Backend middleware validates JWT - - Invalid token triggers redirect to login - -### Service Layer Integration - -**File:** `apps/web/services/menteeService.ts` & `roleService.ts` - -Features: -- ✅ All functions return Promises (async) -- ✅ In-memory cache with 5-minute TTL -- ✅ Automatic cache invalidation on mutations -- ✅ Graceful error handling with defaults -- ✅ TypeScript types for all responses -- ✅ Server-side rendering compatible - -**Frontend Functions** → **Backend Endpoints:** - -``` -registerMentee() → POST /api/auth/mentee-register -getMenteeRequests() → GET /api/mentee-requests -updateMenteeStatus() → PATCH /api/mentee-requests/:id -loginMentee() → POST /api/auth/mentee/login -loginMenteeByEmail() → POST /api/auth/mentee/login -getMenteeQuestions() → GET /api/mentees/:username/questions -updateQuestionProgress() → PATCH /api/mentees/:username/questions/:questionId -updateQuestionDetails() → PATCH /api/mentees/:username/questions/:questionId -getMenteeProfile() → GET /api/mentees/:profileUsername/profile -getLeaderboard() → GET /api/leaderboard -getMentorProfile() → GET /api/mentor/profile -updateMentorProfile() → PATCH /api/mentor/profile -selectRole() → POST /api/auth/select-role -getSelectedRole() → GET /api/auth/get-role -``` - -## Security Best Practices Implemented - -### 1. **CORS Configuration** -✅ Backend explicitly allows frontend origin only -```go -AllowOrigins: []string{cfg.FrontendOrigin}, -AllowCredentials: true, -AllowMethods: [...specific methods...], -``` - -### 2. **Authentication & Authorization** -✅ JWT-based authentication -✅ Automatic token refresh handling -✅ Clear tokens on unauthorized (401) responses -✅ Tokens NOT exposed in responses headers (secure) - -### 3. **Transport Security** -✅ HTTPS ready (use in production) -✅ CORS credentials enabled for secure cookies -✅ X-Requested-With header prevents CSRF -✅ Content-Type validation required - -### 4. **Input Validation** -✅ Role validation in frontend service layer (defense in depth) -✅ Backend validates all inputs before database queries -✅ Error messages don't leak sensitive information - -### 5. **Error Handling** -✅ Centralized error handling via `APIError` class -✅ Console warnings for debugging, not user-facing -✅ Generic error messages to prevent information leakage - -### 6. **Environment Variables** -✅ Sensitive values (JWT_SECRET) never committed -✅ Different configs for local dev and Docker -✅ Production environment uses secure defaults - -### 7. **Session Management** -✅ Tokens stored in localStorage (XSS-protected via CSP in production) -✅ RefreshToken for token rotation support -✅ Token expiration: 1 hour (access), 24 hours (refresh) - -## Environment Variables Reference - -### Frontend (`apps/web/.env.local`) -``` -NEXT_PUBLIC_API_URL=http://localhost:8080/api -NEXT_PUBLIC_ENVIRONMENT=development -``` - -### Backend (`apps/server/.env`) -``` -PORT=8080 -FRONTEND_ORIGIN=http://localhost:3000 -JWT_SECRET= -JWT_EXPIRES=1h -``` - -### Docker Services -- **API connects to Database:** `postgres://coderz-space:coderz-space@postgres:5432/coderz` -- **Web connects to API:** `http://api:8080/api` -- **Frontend connects from outside:** `http://localhost:8080/api` - -## Troubleshooting - -### CORS Errors -**Fix:** Update `FRONTEND_ORIGIN` in backend `.env` to match frontend URL - -### API Connection Failed -**Check:** -- Is backend running? `curl http://localhost:8080/api/health` -- Do hostnames match in docker-compose? -- Is firewall blocking ports? - -### Docker Networking Issues -**Solution:** Services communicate via docker service names (e.g., `api`, `postgres`) -Don't use `localhost` inside Docker containers. - -### Token Expiration -Clear tokens on 401, user redirected to login page automatically. - -## Component Usage Example - -```typescript -// components/LoginForm.tsx -import { loginMentee } from "@/services/menteeService"; -import { selectRole } from "@/services/roleService"; - -export async function handleLogin(username: string, password: string) { - try { - const { token, mentee } = await loginMentee(username, password); - // Token auto-stored by API client - await selectRole("mentee"); - // Redirect to dashboard - } catch (error) { - console.error("Login failed:", error.message); - // Show user-friendly error - } -} -``` - -## Production Considerations - -1. **Use HTTPS** - All API calls over HTTPS -2. **Environment Secrets** - Use secure vault for JWT_SECRET -3. **Database** - Use managed PostgreSQL service (AWS RDS, etc.) -4. **CSP Headers** - Add Content-Security-Policy headers -5. **Rate Limiting** - Implement rate limiting on backend -6. **Logging** - Monitor auth failures and errors -7. **Refresh Token Rotation** - Implement secure refresh token rotation -8. **HTTPS Enforced** - Redirect HTTP to HTTPS - -## Next Steps - -1. Implement remaining backend endpoints as needed -2. Add request/response logging middleware -3. Implement rate limiting -4. Add comprehensive error handling tests -5. Set up CI/CD pipeline for Docker builds -6. Configure production secrets management diff --git a/API_SETUP.md b/API_SETUP.md deleted file mode 100644 index 799d772..0000000 --- a/API_SETUP.md +++ /dev/null @@ -1,274 +0,0 @@ -# Coderz.space - Complete API Integration Guide - -Welcome! This project has been fully integrated with API support. Both the frontend (Next.js) and backend (Go server) are now connected via Docker and ready for development and deployment. - -## 🚀 Quick Start (Choose One) - -### Option A: Docker (Recommended - One Command) -```bash -docker-compose up --build -``` -Then open: -- Frontend: http://localhost:3000 -- Backend API: http://localhost:8080/api -- Health: http://localhost:8080/api/health - -### Option B: Local Development (Two Terminals) - -**Terminal 1 - Backend:** -```bash -cd apps/server -go run cmd/main.go -``` - -**Terminal 2 - Frontend:** -```bash -cd apps/web -npm install -npm run dev -``` - -Requires: PostgreSQL running on localhost:5432 - -## 📚 Documentation - -### Essential Reading -- **[QUICKSTART.md](./QUICKSTART.md)** ← Start here (5 min read) -- **[API_INTEGRATION.md](./API_INTEGRATION.md)** ← Architecture & endpoints (detailed) -- **[SECURITY_AUDIT.md](./SECURITY_AUDIT.md)** ← Security implementation - -### Troubleshooting & Debugging -- **[DOCKER_DEBUG.md](./DOCKER_DEBUG.md)** ← Docker troubleshooting -- **[IMPLEMENTATION_SUMMARY.md](./IMPLEMENTATION_SUMMARY.md)** ← What changed - -## 🏗️ Project Structure - -``` -coderz.space/ -├── apps/ -│ ├── web/ # Next.js frontend -│ │ ├── services/ # API integration layer ✨ UPDATED -│ │ │ ├── api.ts # HTTP client (NEW) -│ │ │ ├── roleService.ts # ✨ NOW API-INTEGRATED -│ │ │ └── menteeService.ts # ✨ NOW API-INTEGRATED -│ │ ├── .env.local # Local config (NEW) -│ │ ├── .env.production # Docker config (NEW) -│ │ └── Dockerfile # Already present -│ │ -│ ├── server/ # Go backend -│ │ ├── .env # Config (NEW) -│ │ ├── .env.example # Template (UPDATED) -│ │ ├── dockerfile # Docker build (NEW) -│ │ └── cmd/main.go # Entry point -│ │ -│ └── mobile/ # React Native app -│ -├── docker-compose.yml # Orchestration (NEW) ✨ -├── QUICKSTART.md # Get started (NEW) -├── API_INTEGRATION.md # Full guide (NEW) -├── SECURITY_AUDIT.md # Security details (NEW) -├── DOCKER_DEBUG.md # Debugging help (NEW) -└── IMPLEMENTATION_SUMMARY.md # What changed (NEW) -``` - -## ✨ What's New - -### Frontend (apps/web/) -✅ Secure HTTP client with automatic auth token injection -✅ API-integrated services (roleService, menteeService) -✅ Environment configuration for local & Docker -✅ In-memory caching with TTL -✅ Graceful error handling -✅ Full TypeScript support - -### Backend (apps/server/) -✅ Dockerfile for containerization -✅ Environment configuration for Docker -✅ Updated .env.example with explanations - -### DevOps -✅ Root docker-compose.yml for full orchestration -✅ PostgreSQL, API, Web, and migrations all included -✅ Health checks for each service -✅ Volume management for database persistence - -### Documentation -✅ 5 comprehensive guides (QUICKSTART, API, SECURITY, DEBUG, SUMMARY) -✅ Setup instructions -✅ API endpoint mapping -✅ Security best practices -✅ Troubleshooting guides - -## 🔐 Security Highlights - -✓ **JWT Authentication** - Secure token-based auth -✓ **Auto Token Injection** - Tokens added to every request automatically -✓ **CORS Protection** - Only frontend can access API -✓ **Error Sanitization** - Generic error messages (no info leakage) -✓ **Type Safety** - Full TypeScript for runtime safety -✓ **Environment Secrets** - Never hardcoded, using .env -✓ **Cache Layer** - Reduces API surface area -✓ **Timeout Protection** - 10-second request timeouts - -## 📊 API Integration Status - -| Component | Status | Location | -|-----------|--------|----------| -| HTTP Client | ✅ Complete | `apps/web/services/api.ts` | -| Role Service | ✅ Complete | `apps/web/services/roleService.ts` | -| Mentee Service | ✅ Complete | `apps/web/services/menteeService.ts` | -| Environment Config | ✅ Complete | `.env` files | -| Docker Orchestration | ✅ Complete | `docker-compose.yml` | -| Documentation | ✅ Complete | 5 guide files | - -## 🎯 Next Steps - -### 1. **Get It Running** (5 minutes) -```bash -docker-compose up --build -``` - -### 2. **Read QUICKSTART** (5 minutes) -Open [QUICKSTART.md](./QUICKSTART.md) for overview - -### 3. **Understand Architecture** (15 minutes) -Read [API_INTEGRATION.md](./API_INTEGRATION.md) for full details - -### 4. **Check Security** (10 minutes) -Review [SECURITY_AUDIT.md](./SECURITY_AUDIT.md) for practices - -### 5. **Implement Backend Endpoints** (Ongoing) -- Backend needs to implement the 18+ mapped endpoints -- Frontend is ready to consume them -- See [API_INTEGRATION.md](./API_INTEGRATION.md) for complete mapping - -## 📋 Environment Variables - -### Frontend (.env.local for local dev) -``` -NEXT_PUBLIC_API_URL=http://localhost:8080/api -NEXT_PUBLIC_ENVIRONMENT=development -``` - -### Frontend (.env.production for Docker) -``` -NEXT_PUBLIC_API_URL=http://api:8080/api -``` - -### Backend (.env for local dev) -``` -PORT=8080 -FRONTEND_ORIGIN=http://localhost:3000 -JWT_SECRET= -DB_URL=postgres://coderz-space:coderz-space@localhost:5432/coderz -``` - -### Docker Environment -- Services communicate via service names (api, postgres, web) -- Defined in docker-compose.yml - -## 🐳 Docker Commands - -```bash -# Start everything -docker-compose up --build - -# Stop everything -docker-compose down - -# View logs for a service -docker-compose logs -f api -docker-compose logs -f web - -# Run one service -docker-compose up --build api - -# Rebuild everything (hard reset) -docker-compose down -v && docker-compose up --build -``` - -## 🔍 Testing the Integration - -### 1. **Frontend Loads** -``` -http://localhost:3000 -``` -Should load without CORS errors - -### 2. **API Health Check** -```bash -curl http://localhost:8080/api/health -# {"status":"ok","timestamp":"..."} -``` - -### 3. **Test Login Flow** (Once backend endpoints implemented) -```bash -# Register -curl -X POST http://localhost:8080/api/auth/mentee-register \ - -H "Content-Type: application/json" \ - -d '{"firstName":"John","lastName":"Doe","username":"johndoe","email":"john@example.com","passwordHash":"hashed"}' - -# Login -curl -X POST http://localhost:8080/api/auth/mentee/login \ - -H "Content-Type: application/json" \ - -d '{"username":"johndoe","password":"password"}' -``` - -## ✅ Features Preserved - -- ✅ All existing UI components work -- ✅ All styling and layouts intact -- ✅ Dashboard functionality preserved -- ✅ Leaderboard display ready -- ✅ Profile pages working -- ✅ Role-based navigation functioning -- ✅ No breaking changes - -## 🎓 Learning Resources - -### For Frontend Developers -- React & Next.js usage unchanged -- Services now return Promises -- See [API_INTEGRATION.md](./API_INTEGRATION.md) for component examples - -### For Backend Developers -- API endpoints defined in [API_INTEGRATION.md](./API_INTEGRATION.md) -- Implement handlers according to spec -- Database queries already set up (sqlc) - -### For DevOps/SRE -- Docker Compose for local orchestration -- See [DOCKER_DEBUG.md](./DOCKER_DEBUG.md) for troubleshooting -- Production checklist in [API_INTEGRATION.md](./API_INTEGRATION.md) - -## 📞 Support & Troubleshooting - -| Issue | Solution | -|-------|----------| -| Can't start Docker | Check [DOCKER_DEBUG.md](./DOCKER_DEBUG.md) | -| CORS errors | Check FRONTEND_ORIGIN in backend .env | -| Port already in use | Kill other services or change ports | -| Database won't start | Check PostgreSQL installation | - -## 🔗 Important Links - -- **[QUICKSTART.md](./QUICKSTART.md)** - 5-minute setup -- **[API_INTEGRATION.md](./API_INTEGRATION.md)** - 30-minute deep dive -- **[SECURITY_AUDIT.md](./SECURITY_AUDIT.md)** - Security details -- **[DOCKER_DEBUG.md](./DOCKER_DEBUG.md)** - Troubleshooting -- **[IMPLEMENTATION_SUMMARY.md](./IMPLEMENTATION_SUMMARY.md)** - What changed - -## 🎉 Ready to Go - -Your API integration is complete and production-ready. All services can run: -- ✅ Locally for development -- ✅ In Docker for isolation -- ✅ In orchestrated containers for production - -**Start here:** [QUICKSTART.md](./QUICKSTART.md) - ---- - -**Last Updated:** March 31, 2026 -**Status:** ✅ Complete -**Version:** 1.0.0 diff --git a/DOCKER_DEBUG.md b/DOCKER_DEBUG.md deleted file mode 100644 index 3959ac0..0000000 --- a/DOCKER_DEBUG.md +++ /dev/null @@ -1,393 +0,0 @@ -# Docker Debugging Guide - -## Environment Variables Inside Docker - -When services run in Docker, they can communicate via service names: - -```yaml -# docker-compose.yml defines: -services: - api: # Service name = hostname - web: # Can access api as: http://api:8080 - postgres: # Can access db as: postgres:5432 -``` - -## Environment Variable Mapping - -### Frontend Service Names -Inside Docker container, frontend connects to: -``` -NEXT_PUBLIC_API_URL=http://api:8080/api -``` - -From your laptop browser, connect to: -``` -http://localhost:3000 → calls → http://localhost:8080/api -``` - -### Backend Service Names -Inside Docker container, backend connects to: -``` -DB_URL=postgres://user:pass@postgres:5432/coderz -``` - -From your laptop psql client, connect to: -``` -psql -h localhost -p 5432 -U coderz-space coderz -``` - -## Verification Commands - -### Check if containers are running -```bash -docker ps -``` - -Expected output: -``` -coderz-api -coderz-web -coderz-postgres -``` - -### Check container logs -```bash -# API logs -docker-compose logs api - -# Web logs -docker-compose logs web - -# Database logs -docker-compose logs postgres - -# Follow logs in real-time -docker-compose logs -f api -``` - -### Test API from container -```bash -# From your laptop -curl http://localhost:8080/api/health - -# Expected response -{"status":"ok","timestamp":"2026-03-31T..."} -``` - -### Test network connectivity inside containers -```bash -# Open shell in API container -docker-compose exec api sh - -# Inside container, test DB connection -nc -zv postgres 5432 # Should show: postgres:5432 open - -# Test API health -wget http://localhost:8080/api/health -O - -``` - -## Common Issues - -### Issue: "Connection refused" to API from frontend - -**Cause:** Frontend using `http://localhost:8080` instead of `http://api:8080` inside Docker - -**Solution:** -Check `.env.production`: -``` -# Wrong for Docker -NEXT_PUBLIC_API_URL=http://localhost:8080/api - -# Correct for Docker -NEXT_PUBLIC_API_URL=http://api:8080/api -``` - -**Rebuild:** `docker-compose up --build web` - -### Issue: Database migrations not running - -**Check migration logs:** -```bash -docker-compose logs migrate -``` - -**Common causes:** -- PostgreSQL not healthy yet (wait for health check) -- Wrong DB connection string -- Missing migration files - -**Fix:** -```bash -docker-compose down -v # Remove volume -docker-compose up --build # Rebuild everything -``` - -### Issue: Port already in use - -**Cause:** Another service using port 3000, 8080, or 5432 - -**Solution:** -```bash -# Find what's using the port (Linux/Mac) -lsof -i :8080 - -# Kill it -kill - -# Or change port in docker-compose.yml -# ports: -# - "8081:8080" # Changed from 8080 -``` - -### Issue: Containers keep restarting - -**Check logs:** -```bash -docker-compose logs -``` - -**Common causes:** -- Database not initialized -- Wrong environment variables -- Port conflicts -- Out of memory - -**Debug:** -```bash -# Run container in foreground to see errors -docker-compose run --rm api sh - -# Inside container, run server manually -./server # See actual error -``` - -### Issue: Frontend can't see API even though it's running - -**Check:** -1. Is API health check passing? - ```bash - docker-compose ps - # Look for "healthy" status - ``` - -2. Is web connected to network? - ```bash - docker network inspect coderz-network - # Should list both 'api' and 'web' containers - ``` - -3. Can web reach API from container? - ```bash - docker-compose exec web wget -O - http://api:8080/api/health - ``` - -**Solution:** -```bash -docker-compose down -docker-compose up --build -``` - -## Environment Variable Debugging - -### Print environment inside container -```bash -# In API container -docker-compose exec api env | grep -E "API|DB|FRONTEND" - -# In web container -docker-compose exec web env | grep -E "NEXT_PUBLIC" -``` - -### Verify environment variables loaded -Check container startup logs: -```bash -docker-compose logs api | grep -E "PORT|ORIGIN|DATABASE" -``` - -### Override environment at runtime -```bash -docker run -e NEXT_PUBLIC_API_URL=http://example.com coderz-web -``` - -## Performance Debugging - -### Container resource usage -```bash -docker stats # See CPU, memory, network usage - -# Monitor specific container -docker stats coderz-api -``` - -### Slow startup? -```bash -# Check when each step completed -docker-compose logs --timestamps api - -# Timings: -# 1. Build image (~30s) -# 2. Start database (~5s) -# 3. Run migrations (~5s) -# 4. Start API (~2s) -# 5. Start web (~15s) -``` - -## Volume & Persistence - -### Check volume status -```bash -docker volume ls | grep coderz -docker volume inspect coderz-postgres-data -``` - -### Remove volume (WARNING: deletes data!) -```bash -docker-compose down -v -``` - -### Backup database from Docker -```bash -docker-compose exec postgres pg_dump -U coderz-space coderz > backup.sql -``` - -### Restore database -```bash -cat backup.sql | docker-compose exec -T postgres psql -U coderz-space coderz -``` - -## Network Debugging - -### Inspect docker network -```bash -docker network inspect coderz-network -``` - -Shows all containers connected and their IP addresses. - -### Test DNS resolution inside container -```bash -docker-compose exec api nslookup postgres -# Should resolve to 172.x.x.x -``` - -### Check exposed ports -```bash -docker ps --format "table {{.Names}}\t{{.Ports}}" -``` - -## Security Verification - -### Check CORS headers -```bash -curl -H "Origin: http://localhost:3000" \ - -H "Access-Control-Request-Method: POST" \ - http://localhost:8080/api/health -v -``` - -Should see `Access-Control-Allow-Origin: http://localhost:3000` - -### Verify JWT validation -1. Login to get token -2. Test with wrong token -3. Should get 401 Unauthorized - -### Check auth flow -```bash -# Login -TOKEN=$(curl -X POST http://localhost:8080/api/auth/mentee/login \ - -H "Content-Type: application/json" \ - -d '{"username":"testuser","password":"testpass"}' | jq -r '.token') - -# Use token -curl -H "Authorization: Bearer $TOKEN" \ - http://localhost:8080/api/mentees/testuser/profile -``` - -## Rebuild & Restart - -### Rebuild everything -```bash -docker-compose down -docker-compose up --build -``` - -### Rebuild specific service -```bash -docker-compose up --build api # Rebuild only API -``` - -### Hard reset (remove everything) -```bash -docker-compose down -v # Stop & remove volumes -docker system prune -a # Clean unused images -docker-compose up --build # Fresh start -``` - -## Production Debugging - -### Enable debug mode -Add to `.env`: -``` -LOG_LEVEL=debug -``` - -Rebuild: -```bash -docker-compose up --build -``` - -### View request/response in logs -API logs should show: -- Request method & path -- Response status code -- Processing time - -Frontend logs (browser console): -- API call details -- Response data or errors - -### Monitor API metrics -```bash -# Check response times -docker-compose logs api | grep "duration" - -# Find slow requests (>1s) -docker-compose logs api | grep "duration.*[1-9][0-9][0-9][0-9]ms" -``` - -## Extracting Logs for Support - -```bash -# Save all logs to file -docker-compose logs > debug.log - -# Just API logs -docker-compose logs api > api.log - -# With timestamps -docker-compose logs --timestamps > debug_time.log - -# Follow in real-time -docker-compose logs -f -``` - -## Quick Reference - -| Command | Purpose | -|---------|---------| -| `docker-compose up` | Start all services | -| `docker-compose down` | Stop all services | -| `docker-compose ps` | List running containers | -| `docker-compose logs api` | View API logs | -| `docker-compose exec api sh` | Shell into API container | -| `docker-compose build` | Rebuild images | -| `docker stats` | Monitor resource usage | -| `docker system prune -a` | Clean up everything | - -## Getting Help - -1. Check logs first: `docker-compose logs -f` -2. Review [QUICKSTART.md](./QUICKSTART.md) troubleshooting -3. Check [API_INTEGRATION.md](./API_INTEGRATION.md) for architecture -4. Verify all containers healthy: `docker-compose ps` -5. Try full reset: `docker-compose down -v && docker-compose up --build` diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index ae9bffa..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -1,186 +0,0 @@ -# Quick Start - API Integration - -## 📋 What's Been Set Up - -- ✅ Secure HTTP client (`services/api.ts`) -- ✅ API-integrated services (`roleService.ts`, `menteeService.ts`) -- ✅ JWT authentication with auto-token injection -- ✅ Environment configuration for local & Docker -- ✅ Docker Compose with Web + API + PostgreSQL -- ✅ Security best practices implemented -- ✅ Comprehensive documentation - -## 🚀 Get Started - -### Option 1: Full Stack in Docker (Recommended) - -```bash -# From project root -docker-compose up --build - -# Services will be available at: -# Frontend: http://localhost:3000 -# Backend: http://localhost:8080/api -# Health: http://localhost:8080/api/health -``` - -### Option 2: Local Development - -**Terminal 1 - Backend:** -```bash -cd apps/server -go run cmd/main.go -# Runs on http://localhost:8080/api -``` - -**Terminal 2 - Frontend:** -```bash -cd apps/web -npm install -npm run dev -# Runs on http://localhost:3000 -``` - -**Start PostgreSQL independently:** -- Docker: `docker run -p 5432:5432 -e POSTGRES_USER=coderz-space -e POSTGRES_PASSWORD=coderz-space postgres:18` -- Or use local PostgreSQL installation - -## 📝 Environment Setup - -### For Local Development Edit - -**`apps/web/.env.local`:** -``` -NEXT_PUBLIC_API_URL=http://localhost:8080/api -NEXT_PUBLIC_ENVIRONMENT=development -``` - -**`apps/server/.env`:** -- Already configured for localhost -- Update `FRONTEND_ORIGIN` if using different frontend URL - -## 🧪 Test the Integration - -### Check API Health -```bash -curl http://localhost:8080/api/health -# Response: {"status":"ok","timestamp":"2026-03-31T..."} -``` - -### Test Frontend Connection -1. Open http://localhost:3000 -2. Browser console should not show CORS errors -3. Try logging in - requests should go to backend - -## 📚 Documentation - -- **API Integration Guide:** [API_INTEGRATION.md](./API_INTEGRATION.md) -- **Security Audit:** [SECURITY_AUDIT.md](./SECURITY_AUDIT.md) - -## 🔧 Docker Commands - -```bash -# Start everything -docker-compose up --build - -# Stop everything -docker-compose down - -# View logs -docker-compose logs -f api -docker-compose logs -f web - -# Rebuild specific service -docker-compose up --build api - -# Run backend only -docker-compose up postgres migrate api - -# Clean up everything (including data) -docker-compose down -v -``` - -## 🔌 API Endpoints (Implemented) - -All endpoints below are integrated in frontend services: - -### Authentication -- `POST /api/auth/mentee-register` - Register new mentee -- `POST /api/auth/mentee/login` - Login mentee -- `POST /api/auth/select-role` - Select user role -- `GET /api/auth/get-role` - Get selected role - -### Mentee Management -- `GET /api/mentee-requests` - Get all mentee requests (admin) -- `PATCH /api/mentee-requests/:id` - Update mentee status -- `DELETE /api/mentee-requests/:id` - Delete mentee -- `GET /api/mentees/:username/profile` - Get mentee profile -- `PATCH /api/mentees/:username/profile` - Update mentee profile -- `PATCH /api/mentees/:username/password` - Change password - -### Questions & Progress -- `GET /api/mentees/:username/questions` - Get questions -- `PATCH /api/mentees/:username/questions/:questionId` - Update progress/notes -- `GET /api/mentees/:username/questions/:questionId` - Get question detail - -### Leaderboard -- `GET /api/leaderboard` - Get mentee rankings - -### Mentor -- `GET /api/mentor/profile` - Get mentor profile -- `PATCH /api/mentor/profile` - Update mentor profile -- `PATCH /api/mentor/password` - Change password - -### Health -- `GET /api/health` - Health check - -## 🛡️ Security Features - -✅ **CORS Protection** - Only frontend can access API -✅ **JWT Authentication** - Secure token-based auth -✅ **Auto Token Injection** - No manual header management -✅ **Centralized Error Handling** - Generic error messages -✅ **Cache Layer** - Reduced API load with TTL -✅ **Type Safety** - Full TypeScript support - -## 🚨 Common Issues - -| Issue | Solution | -|-------|----------| -| CORS Error | Check FRONTEND_ORIGIN in `.env` | -| Cannot connect to DB | Ensure PostgreSQL is running | -| Port already in use | `docker-compose down` or change ports | -| API not responding | Check logs: `docker-compose logs api` | - -## 📦 Dependencies Added - -- **Frontend:** `axios@^1.7.0` (HTTP client) -- **Backend:** Already complete - -Install frontend dependencies: -```bash -cd apps/web -npm install -``` - -## ✅ Features Keeping Existing UI - -All frontend components remain unchanged: -- UI components, layouts, and styling intact -- Only service layer implementations updated -- Backward compatible with existing component code -- No breaking changes to component APIs - -## 🎯 Next: Implement Backend Endpoints - -The frontend is now ready. Backend should implement the API endpoints mapped in `API_INTEGRATION.md`. - -Start with these core endpoints: -1. Auth endpoints (login, register, role selection) -2. Mentee questions endpoint -3. Profile endpoints -4. Leaderboard endpoint - -## 📞 Support - -See `API_INTEGRATION.md` for detailed troubleshooting and architecture diagrams. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md deleted file mode 100644 index 982725d..0000000 --- a/SECURITY_AUDIT.md +++ /dev/null @@ -1,165 +0,0 @@ -# API Integration Security Audit - -## Security Checklist ✓ - -### Authentication & Authorization -- ✅ **JWT-based Authentication**: Stateless, scalable authentication -- ✅ **Automatic Token Injection**: Auth token added to all requests automatically -- ✅ **Token Storage**: Secure localStorage storage with clear on 401 -- ✅ **Auth Interceptor**: Request interceptor adds Bearer token -- ✅ **Unauthorized Handling**: 401 responses trigger logout & redirect -- ✅ **Role-based Access**: Frontend enforces role selection before API calls - -### Transport Security -- ✅ **CORS Enforcement**: Backend restricts to specific frontend origin - ```go - AllowOrigins: []string{cfg.FrontendOrigin} - ``` -- ✅ **Credentials Support**: `withCredentials: true` for secure cookies -- ✅ **CSRF Protection**: X-Requested-With header included in requests -- ✅ **Content-Type Validation**: Application/json enforced -- ✅ **Timeout Protection**: 10-second request timeouts prevent hanging - -### Request/Response Handling -- ✅ **Custom Error Class**: APIError wraps axios errors safely -- ✅ **Error Sanitization**: Error messages don't leak implementation details -- ✅ **Response Validation**: Type-safe responses via TypeScript generics -- ✅ **Request Config**: Centralized axios instance prevents misconfiguration -- ✅ **Cache Layer**: In-memory cache reduces API load - -### Input Validation -- ✅ **Frontend Validation**: Role type checking before API calls -- ✅ **Backend Validation**: Should validate all inputs (implement in Go handlers) -- ✅ **Type Safety**: TypeScript prevents invalid data structure passing -- ✅ **Parameter Validation**: IDs and usernames validated by backend - -### Environment & Config -- ✅ **Environment Separation**: Different configs for dev, local, production -- ✅ **Secrets Management**: JWT_SECRET never hardcoded in source -- ✅ **.env Files**: Git-ignored sensitive configuration -- ✅ **Public vs Private**: NEXT_PUBLIC_ prefix controls exposure -- ✅ **Docker Secrets**: Service names used for inter-service communication - -### Error Handling -- ✅ **No Stack Traces**: User doesn't see implementation details -- ✅ **Consistent Errors**: APIError class standardizes format -- ✅ **Silent Failures**: Graceful degradation on network errors -- ✅ **Cache Fallback**: Data returned from cache if API fails -- ✅ **Error Logging**: Console warnings for debugging (not production) - -### Caching Strategy -- ✅ **TTL-based Caching**: 5-minute cache prevents stale data -- ✅ **Cache Invalidation**: Mutations clear relevant cache keys -- ✅ **Memory-safe**: Map-based cache doesn't grow unbounded -- ✅ **No Sensitive Data**: Auth tokens not cached - -### Dependency Security -- ✅ **Axios**: Industry-standard HTTP client, actively maintained -- ✅ **No OAuth Libraries**: JWT used directly (minimal dependencies) -- ✅ **Type Definitions**: @types/axios for type safety -- ✅ **Regular Updates**: npm packages should be updated regularly - -### Frontend Best Practices -- ✅ **SSR-safe**: API client checks for window object -- ✅ **No Client-side Secrets**: JWT_SECRET not exposed to frontend -- ✅ **TypeScript Strict**: Type checking prevents misuse -- ✅ **Error Boundaries**: Each service has try-catch error handling - -## Security Recommendations - -### Immediate (High Priority) -1. **Implement Backend Input Validation** - - Validate all request bodies - - Sanitize user inputs - - Implement SQL injection protection (use parameterized queries in sqlc) - -2. **Add Rate Limiting** - - Prevent brute force attacks - - Use middleware like `echo-rate-limit` - -3. **TLS/HTTPS** - - Use HTTPS in production - - Set Strict-Transport-Security headers - -### Short-term (Medium Priority) -1. **Implement Refresh Token Rotation** - - Issue new refresh tokens on each use - - Invalidate old refresh tokens - -2. **Add Request Logging** - - Log all authentication attempts - - Monitor for suspicious patterns - -3. **Implement HSTS Headers** - - Force HTTPS for all future requests - - Prevent SSL stripping attacks - -### Medium-term (Nice to Have) -1. **OAuth 2.0 Integration** - - Support Google/GitHub login - - Reduces password management burden - -2. **Two-Factor Authentication** - - Time-based OTP (TOTP) - - Recovery codes - -3. **Content Security Policy** - - Prevent XSS attacks - - Restrict script sources - -4. **API Key Management** - - For service-to-service communication - - Separate from user authentication - -## Security Test Checklist - -### Manual Testing -- [ ] Verify token is cleared on login failure -- [ ] Test 401 response redirects to login -- [ ] Confirm CORS blocks unauthorized origins -- [ ] Test API health endpoint returns 200 -- [ ] Verify CSRF header is present in requests - -### Automated Testing (Future) -- [ ] Unit tests for error handling -- [ ] Integration tests for auth flow -- [ ] E2E tests for login/logout -- [ ] Security scanning with OWASP ZAP -- [ ] Dependency scanning with Snyk - -## Threat Model Mitigation - -| Threat | Mitigation | -|--------|-----------| -| **XSS (Cross-site Scripting)** | CSP headers (production), React escaping | -| **CSRF (Cross-site Request Forgery)** | X-Requested-With header, SameSite cookies | -| **SQL Injection** | sqlc prevents (uses parameterized queries) | -| **Unauthorized Access** | JWT validation, role-based checks | -| **Man-in-the-Middle** | HTTPS/TLS (production) | -| **Brute Force** | Rate limiting (future) | -| **Token Theft** | localStorage with HTTPS, clear on 401 | -| **Information Disclosure** | Generic error messages, no stack traces | - -## Compliance Considerations - -- **GDPR**: Ensure user data deletion endpoints exist -- **CCPA**: Provide data export functionality -- **PCI DSS**: If handling payments, follow PCI standards -- **HIPAA**: If health data, implement additional controls - -## Code Review Points - -1. ✅ No hardcoded secrets in code -2. ✅ Environment variables properly configured -3. ✅ Error messages are generic (not implementation-specific) -4. ✅ All external inputs validated -5. ✅ Dependencies kept updated -6. ✅ No console.log with sensitive data in production -7. ✅ CORS origin strictly configured -8. ✅ Database queries use parameterized statements - -## References - -- OWASP Top 10: https://owasp.org/www-project-top-ten/ -- JWT Best Practices: https://tools.ietf.org/html/rfc8725 -- REST API Security: https://restfulapi.net/security-essentials/ diff --git a/apps/Makefile b/apps/Makefile new file mode 100644 index 0000000..10d70ea --- /dev/null +++ b/apps/Makefile @@ -0,0 +1,42 @@ +SHELL := /bin/sh + +SERVER_DIR := server +WEB_DIR := web +SERVER_COMPOSE := docker compose --env-file $(SERVER_DIR)/.env -f $(SERVER_DIR)/docker-compose.yml +WEB_IMAGE := coderz-space-web +WEB_CONTAINER := coderz-space-web +SERVER_NETWORK := coderz-space-bootcamp_default + +.PHONY: help server-dev server-prod web-dev web-prod + +help: + @echo "Available targets:" + @echo " make server-dev Run the Go API locally with Dockerized Postgres" + @echo " make server-prod Run the API as a Docker Compose service" + @echo " make web-dev Run the Next.js app locally in dev mode" + @echo " make web-prod Run the Next.js app as a Docker container" + +server-dev: + @$(MAKE) -C $(SERVER_DIR) docker-up + @$(MAKE) -C $(SERVER_DIR) migrate-up + @$(MAKE) -C $(SERVER_DIR) run + +server-prod: + @$(SERVER_COMPOSE) up -d postgres + @$(MAKE) -C $(SERVER_DIR) migrate-up + @$(SERVER_COMPOSE) up --build api + +web-dev: + @npm --prefix $(WEB_DIR) run dev + +web-prod: + @$(SERVER_COMPOSE) up -d postgres + @$(MAKE) -C $(SERVER_DIR) migrate-up + @$(SERVER_COMPOSE) up -d --build api + @docker build -t $(WEB_IMAGE) -f $(WEB_DIR)/Dockerfile $(WEB_DIR) + @docker rm -f $(WEB_CONTAINER) >/dev/null 2>&1 || true + @docker run --rm --name $(WEB_CONTAINER) \ + --network $(SERVER_NETWORK) \ + --env-file $(WEB_DIR)/.env.production \ + -p 3000:3000 \ + $(WEB_IMAGE) diff --git a/apps/server/.env.example b/apps/server/.env.example index bacc436..573684c 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -18,6 +18,7 @@ DB_DSN=postgresql://coderz-space:coderz-space@postgres:5432/coderz?sslmode=disab # Database Configuration DB_URL=postgres://coderz-space:coderz-space@localhost:5432/coderz?sslmode=disable +DB_DSN=postgres://coderz-space:coderz-space@postgres:5432/coderz?sslmode=disable MAX_DB_CONNS=10 MIN_DB_CONNS=2 MAX_DB_CONN_LIFETIME=1h diff --git a/apps/server/Makefile b/apps/server/Makefile index 2dde6f5..66081c3 100644 --- a/apps/server/Makefile +++ b/apps/server/Makefile @@ -54,7 +54,6 @@ db-init: reset-db: @echo "Dropping database schema..." docker compose exec postgres psql -U coderz-space -d coderz -c "DROP SCHEMA IF EXISTS coderz CASCADE;" - docker compose run --rm migrate -path /migrations -database "${DB_DSN}" drop -f @echo "Re-applying all migrations..." $(MAKE) migrate-up @echo "Database reset complete!" @@ -83,4 +82,4 @@ docker-up: docker-down: @echo "Stopping docker containers..." - @docker compose down \ No newline at end of file + @docker compose down diff --git a/apps/server/db/migrations/0002_algo_buddy_app.down.sql b/apps/server/db/migrations/0002_algo_buddy_app.down.sql new file mode 100644 index 0000000..4c15c5a --- /dev/null +++ b/apps/server/db/migrations/0002_algo_buddy_app.down.sql @@ -0,0 +1,30 @@ +SET search_path TO coderz, public; + +DROP TRIGGER IF EXISTS trg_mentee_day_assignments_updated_at ON mentee_day_assignments; +DROP TABLE IF EXISTS mentee_day_assignments; + +DROP TRIGGER IF EXISTS trg_mentee_requests_updated_at ON mentee_requests; +DROP TABLE IF EXISTS mentee_requests; + +ALTER TABLE assignment_problems + DROP CONSTRAINT IF EXISTS chk_assignment_problems_app_progress_status; + +ALTER TABLE assignment_problems + DROP COLUMN IF EXISTS app_progress_status, + DROP COLUMN IF EXISTS resources; + +ALTER TABLE bootcamp_enrollments + DROP CONSTRAINT IF EXISTS chk_bootcamp_enrollments_assigned_sheet_key; + +ALTER TABLE bootcamp_enrollments + DROP COLUMN IF EXISTS assigned_sheet_key; + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS chk_users_username_format, + DROP CONSTRAINT IF EXISTS uq_users_username; + +ALTER TABLE users + DROP COLUMN IF EXISTS linkedin_url, + DROP COLUMN IF EXISTS github_url, + DROP COLUMN IF EXISTS bio, + DROP COLUMN IF EXISTS username; diff --git a/apps/server/db/migrations/0002_algo_buddy_app.up.sql b/apps/server/db/migrations/0002_algo_buddy_app.up.sql new file mode 100644 index 0000000..5d7b8c8 --- /dev/null +++ b/apps/server/db/migrations/0002_algo_buddy_app.up.sql @@ -0,0 +1,129 @@ +SET search_path TO coderz, public; + +ALTER TABLE users + ADD COLUMN username VARCHAR(80), + ADD COLUMN bio TEXT, + ADD COLUMN github_url TEXT, + ADD COLUMN linkedin_url TEXT; + +ALTER TABLE users + ALTER COLUMN username SET DEFAULT ('user_' || REPLACE(LEFT(uuidv7()::text, 8), '-', '')); + +WITH prepared AS ( + SELECT + id, + COALESCE( + NULLIF( + LOWER(REGEXP_REPLACE(SPLIT_PART(COALESCE(email, ''), '@', 1), '[^a-zA-Z0-9_]+', '', 'g')), + '' + ), + NULLIF( + LOWER(REGEXP_REPLACE(COALESCE(name, ''), '[^a-zA-Z0-9_]+', '', 'g')), + '' + ), + 'user' + ) AS base_username + FROM users +), +ranked AS ( + SELECT + id, + base_username, + ROW_NUMBER() OVER (PARTITION BY base_username ORDER BY id) AS seq + FROM prepared +) +UPDATE users u +SET username = CASE + WHEN ranked.seq = 1 THEN ranked.base_username + ELSE ranked.base_username || ranked.seq::text +END +FROM ranked +WHERE ranked.id = u.id + AND u.username IS NULL; + +ALTER TABLE users + ALTER COLUMN username SET NOT NULL; + +ALTER TABLE users + ADD CONSTRAINT uq_users_username UNIQUE (username), + ADD CONSTRAINT chk_users_username_format CHECK (username ~ '^[a-z0-9_]+$'); + +ALTER TABLE bootcamp_enrollments + ADD COLUMN assigned_sheet_key VARCHAR(64); + +ALTER TABLE bootcamp_enrollments + ADD CONSTRAINT chk_bootcamp_enrollments_assigned_sheet_key + CHECK ( + assigned_sheet_key IS NULL + OR assigned_sheet_key IN ('gfg-dsa-360', 'strivers-dsa-sheet') + ); + +ALTER TABLE assignment_problems + ADD COLUMN resources TEXT, + ADD COLUMN app_progress_status VARCHAR(32) NOT NULL DEFAULT 'not_started'; + +ALTER TABLE assignment_problems + ADD CONSTRAINT chk_assignment_problems_app_progress_status + CHECK ( + app_progress_status IN ( + 'not_started', + 'discussion_needed', + 'revision_needed', + 'completed' + ) + ); + +CREATE TABLE mentee_requests ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + bootcamp_id UUID NOT NULL REFERENCES bootcamps(id) ON DELETE CASCADE, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + sheet_key VARCHAR(64), + reviewed_by UUID REFERENCES organization_members(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uq_mentee_requests_user_bootcamp UNIQUE (user_id, bootcamp_id), + CONSTRAINT chk_mentee_requests_status CHECK (status IN ('pending', 'approved', 'rejected')), + CONSTRAINT chk_mentee_requests_sheet_key CHECK ( + sheet_key IS NULL + OR sheet_key IN ('gfg-dsa-360', 'strivers-dsa-sheet') + ) +); + +CREATE TRIGGER trg_mentee_requests_updated_at + BEFORE UPDATE ON mentee_requests + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE INDEX idx_mentee_requests_bootcamp_status ON mentee_requests(bootcamp_id, status); +CREATE INDEX idx_mentee_requests_user_id ON mentee_requests(user_id); + +CREATE TABLE mentee_day_assignments ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + bootcamp_enrollment_id UUID NOT NULL REFERENCES bootcamp_enrollments(id) ON DELETE CASCADE, + weekday VARCHAR(16) NOT NULL, + created_by UUID REFERENCES organization_members(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT uq_mentee_day_assignments UNIQUE (bootcamp_enrollment_id, weekday), + CONSTRAINT chk_mentee_day_assignments_weekday CHECK ( + weekday IN ( + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday' + ) + ) +); + +CREATE TRIGGER trg_mentee_day_assignments_updated_at + BEFORE UPDATE ON mentee_day_assignments + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +CREATE INDEX idx_mentee_day_assignments_weekday ON mentee_day_assignments(weekday); diff --git a/apps/server/docker-compose.yml b/apps/server/docker-compose.yml index 1b7740a..34b051f 100644 --- a/apps/server/docker-compose.yml +++ b/apps/server/docker-compose.yml @@ -29,48 +29,48 @@ services: condition: service_healthy restart: on-failure - # server: - # build: - # context: . - # dockerfile: dockerfile - # container_name: coderz-space-server - # ports: - # - "8080:8080" - # environment: - # PORT: 8080 - # DB_URL: postgresql://coderz-space:coderz-space@postgres:5432/coderz?sslmode=disable - # DB_DSN: postgresql://coderz-space:coderz-space@postgres:5432/coderz?sslmode=disable - # JWT_SECRET: ${JWT_SECRET} - # FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} - # ENVIRONMENT: ${ENVIRONMENT:-development} - # LOG_LEVEL: ${LOG_LEVEL:-info} - # FILE_LOG_LEVEL: ${FILE_LOG_LEVEL:-info} - # JWT_EXPIRES: ${JWT_EXPIRES:-1h} - # APP_NAME: ${APP_NAME:-Coderz_Space} - # MAX_DB_CONNS: ${MAX_DB_CONNS:-10} - # MIN_DB_CONNS: ${MIN_DB_CONNS:-2} - # MAX_DB_CONN_LIFETIME: ${MAX_DB_CONN_LIFETIME:-1h} - # MAX_DB_CONN_IDLE_TIME: ${MAX_DB_CONN_IDLE_TIME:-30m} - # depends_on: - # postgres: - # condition: service_healthy - # migrate: - # condition: service_completed_successfully - # restart: unless-stopped - # healthcheck: - # test: - # [ - # "CMD", - # "wget", - # "--no-verbose", - # "--tries=1", - # "--spider", - # "http://localhost:8080/swagger/index.html", - # ] - # interval: 30s - # timeout: 3s - # start_period: 10s - # retries: 3 + api: + build: + context: . + dockerfile: dockerfile + container_name: coderz-space-server + ports: + - "8080:8080" + environment: + APP_NAME: ${APP_NAME:-Coderz_Space} + VERSION: ${VERSION:-0.1.0} + PORT: 8080 + DB_URL: postgresql://coderz-space:coderz-space@postgres:5432/coderz?sslmode=disable + DB_DSN: postgresql://coderz-space:coderz-space@postgres:5432/coderz?sslmode=disable + JWT_SECRET: ${JWT_SECRET} + FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000} + ENVIRONMENT: ${ENVIRONMENT:-development} + LOG_LEVEL: ${LOG_LEVEL:-info} + FILE_LOG_LEVEL: ${FILE_LOG_LEVEL:-info} + JWT_EXPIRES: ${JWT_EXPIRES:-1h} + REFRESH_TOKEN_EXPIRES: ${REFRESH_TOKEN_EXPIRES:-24h} + MAX_DB_CONNS: ${MAX_DB_CONNS:-10} + MIN_DB_CONNS: ${MIN_DB_CONNS:-2} + MAX_DB_CONN_LIFETIME: ${MAX_DB_CONN_LIFETIME:-1h} + MAX_DB_CONN_IDLE_TIME: ${MAX_DB_CONN_IDLE_TIME:-30m} + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:8080/swagger/index.html", + ] + interval: 30s + timeout: 3s + start_period: 10s + retries: 3 volumes: coderz-space-postgres-data: diff --git a/apps/server/dockerfile b/apps/server/dockerfile index 4e87e99..f801846 100644 --- a/apps/server/dockerfile +++ b/apps/server/dockerfile @@ -1,6 +1,6 @@ # Multi-stage build for Go server # Stage 1: Build stage -FROM golang:1.24-alpine AS builder +FROM golang:1.25-alpine AS builder # Install build dependencies RUN apk add --no-cache git make diff --git a/apps/server/internal/common/validator/validator.go b/apps/server/internal/common/validator/validator.go index 3b89a63..d0fdbe3 100644 --- a/apps/server/internal/common/validator/validator.go +++ b/apps/server/internal/common/validator/validator.go @@ -111,6 +111,30 @@ func (v *validator) registerCustomValidators() { if err != nil { panic(err) } + + // Register password complexity validator + err = v.validator.RegisterValidation("password_complexity", func(fl go_validator.FieldLevel) bool { + value := fl.Field().String() + hasLetter := false + hasNumber := false + + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') { + hasLetter = true + } + if char >= '0' && char <= '9' { + hasNumber = true + } + if hasLetter && hasNumber { + return true + } + } + + return false + }) + if err != nil { + panic(err) + } } // you can register your custom validation diff --git a/apps/server/internal/config/config.go b/apps/server/internal/config/config.go index 9143f37..6c9f62e 100644 --- a/apps/server/internal/config/config.go +++ b/apps/server/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "fmt" "os" "strconv" @@ -73,7 +74,7 @@ func parseLevel(level string) zapcore.Level { } func LoadConfig() *Config { - if err := godotenv.Load(envFilePath); err != nil { + if err := godotenv.Load(envFilePath); err != nil && !errors.Is(err, os.ErrNotExist) { panic(fmt.Errorf("failed to load environment variables: %v", err)) } diff --git a/apps/server/internal/config/config_test.go b/apps/server/internal/config/config_test.go new file mode 100644 index 0000000..c1ba3dc --- /dev/null +++ b/apps/server/internal/config/config_test.go @@ -0,0 +1,43 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfigAllowsMissingDotEnvWhenEnvironmentIsProvided(t *testing.T) { + t.Setenv("APP_NAME", "Coderz_Space") + t.Setenv("VERSION", "0.1.0") + t.Setenv("PORT", "8080") + t.Setenv("JWT_SECRET", "secret") + t.Setenv("JWT_EXPIRES", "1h") + t.Setenv("FRONTEND_ORIGIN", "http://localhost:3000") + t.Setenv("DB_URL", "postgres://localhost:5432/coderz?sslmode=disable") + t.Setenv("ENVIRONMENT", "development") + t.Setenv("REFRESH_TOKEN_EXPIRES", "24h") + t.Setenv("MAX_DB_CONN_LIFETIME", "1h") + t.Setenv("MAX_DB_CONN_IDLE_TIME", "30m") + t.Setenv("MAX_DB_CONNS", "10") + t.Setenv("MIN_DB_CONNS", "2") + t.Setenv("LOG_LEVEL", "info") + t.Setenv("FILE_LOG_LEVEL", "info") + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + root := filepath.Dir(filepath.Dir(filepath.Dir(wd))) + tmp := t.TempDir() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir temp dir: %v", err) + } + t.Cleanup(func() { + _ = os.Chdir(root) + }) + + cfg := LoadConfig() + if cfg.AppName != "Coderz_Space" { + t.Fatalf("unexpected app name: %s", cfg.AppName) + } +} diff --git a/apps/server/internal/container/container.go b/apps/server/internal/container/container.go index 1b07845..40ea71d 100644 --- a/apps/server/internal/container/container.go +++ b/apps/server/internal/container/container.go @@ -5,6 +5,7 @@ import ( "github.com/coderz-space/coderz.space/internal/db" db_sqlc "github.com/coderz-space/coderz.space/internal/db/sqlc" "github.com/coderz-space/coderz.space/internal/modules/analytics" + "github.com/coderz-space/coderz.space/internal/modules/app" "github.com/coderz-space/coderz.space/internal/modules/assignment" "github.com/coderz-space/coderz.space/internal/modules/auth" "github.com/coderz-space/coderz.space/internal/modules/bootcamp" @@ -49,6 +50,10 @@ type Container struct { AnalyticsHandler *analytics.Handler AnalyticsService *analytics.Service + // app facade + AppHandler *app.Handler + AppService *app.Service + // DB DB *pgxpool.Pool } @@ -90,6 +95,10 @@ func NewContainer(config *config.Config, logger *zap.Logger) (*Container, error) analyticsService := analytics.NewService(pool) analyticsHandler := analytics.NewHandler(analyticsService) + // Initialize app facade module + appService := app.NewService(pool) + appHandler := app.NewHandler(appService) + container := &Container{ Config: config, Logger: logger, @@ -107,6 +116,8 @@ func NewContainer(config *config.Config, logger *zap.Logger) (*Container, error) ProgressService: progressService, AnalyticsHandler: analyticsHandler, AnalyticsService: analyticsService, + AppHandler: appHandler, + AppService: appService, DB: pool, } return container, nil diff --git a/apps/server/internal/modules/app/data.go b/apps/server/internal/modules/app/data.go new file mode 100644 index 0000000..d1cea52 --- /dev/null +++ b/apps/server/internal/modules/app/data.go @@ -0,0 +1,130 @@ +package app + +import "strings" + +type sheetQuestion struct { + ID string + Title string + Topic string + Difficulty string + Description string +} + +type sheetCatalog struct { + Key string + Name string + Questions []sheetQuestion +} + +var catalogs = map[string]sheetCatalog{ + "gfg-dsa-360": { + Key: "gfg-dsa-360", + Name: "GFG DSA 360", + Questions: []sheetQuestion{ + {ID: "gfg-1", Title: "Array Rotation", Topic: "Arrays", Difficulty: "easy", Description: "Practice array rotation techniques and in-place updates."}, + {ID: "gfg-2", Title: "Kadane's Algorithm", Topic: "Arrays", Difficulty: "medium", Description: "Find the maximum subarray sum using dynamic running totals."}, + {ID: "gfg-3", Title: "Stock Buy and Sell", Topic: "Arrays", Difficulty: "easy", Description: "Track the best buy and sell window for maximum profit."}, + {ID: "gfg-4", Title: "Trapping Rain Water", Topic: "Arrays", Difficulty: "hard", Description: "Compute trapped water using prefix/suffix or two-pointer logic."}, + {ID: "gfg-5", Title: "Reverse a Linked List", Topic: "Linked List", Difficulty: "easy", Description: "Reverse a singly linked list iteratively or recursively."}, + {ID: "gfg-6", Title: "Detect Loop in Linked List", Topic: "Linked List", Difficulty: "medium", Description: "Use fast and slow pointers to detect a cycle."}, + {ID: "gfg-7", Title: "Merge Two Sorted Lists", Topic: "Linked List", Difficulty: "easy", Description: "Merge two sorted linked lists while preserving order."}, + {ID: "gfg-8", Title: "Binary Search", Topic: "Binary Search", Difficulty: "easy", Description: "Implement binary search on a sorted collection."}, + {ID: "gfg-9", Title: "Search in Rotated Array", Topic: "Binary Search", Difficulty: "medium", Description: "Find a target in a rotated sorted array."}, + {ID: "gfg-10", Title: "Balanced Parentheses", Topic: "Stack", Difficulty: "easy", Description: "Validate bracket matching using a stack."}, + {ID: "gfg-11", Title: "Next Greater Element", Topic: "Stack", Difficulty: "medium", Description: "Use a monotonic stack to find next greater values."}, + {ID: "gfg-12", Title: "Level Order Traversal", Topic: "Trees", Difficulty: "easy", Description: "Traverse a binary tree level by level using a queue."}, + {ID: "gfg-13", Title: "Height of Binary Tree", Topic: "Trees", Difficulty: "easy", Description: "Compute binary tree depth using DFS or BFS."}, + {ID: "gfg-14", Title: "Lowest Common Ancestor", Topic: "Trees", Difficulty: "medium", Description: "Find the lowest common ancestor of two nodes."}, + {ID: "gfg-15", Title: "Dijkstra's Algorithm", Topic: "Graphs", Difficulty: "hard", Description: "Compute shortest paths in a weighted graph."}, + }, + }, + "strivers-dsa-sheet": { + Key: "strivers-dsa-sheet", + Name: "Striver's DSA Sheet", + Questions: []sheetQuestion{ + {ID: "stv-1", Title: "Set Matrix Zeroes", Topic: "Arrays", Difficulty: "medium", Description: "Zero matrix rows and columns in-place with minimal extra space."}, + {ID: "stv-2", Title: "Pascal's Triangle", Topic: "Arrays", Difficulty: "easy", Description: "Generate rows of Pascal's triangle."}, + {ID: "stv-3", Title: "Next Permutation", Topic: "Arrays", Difficulty: "medium", Description: "Produce the next lexicographical permutation in-place."}, + {ID: "stv-4", Title: "Maximum Subarray", Topic: "Arrays", Difficulty: "medium", Description: "Find the maximum contiguous subarray sum."}, + {ID: "stv-5", Title: "Sort Colors", Topic: "Arrays", Difficulty: "medium", Description: "Sort three values using the Dutch national flag pattern."}, + {ID: "stv-6", Title: "Two Sum", Topic: "Arrays", Difficulty: "easy", Description: "Return indices of the two numbers that add to the target."}, + {ID: "stv-7", Title: "Reverse Linked List", Topic: "Linked List", Difficulty: "easy", Description: "Reverse a singly linked list."}, + {ID: "stv-8", Title: "Middle of Linked List", Topic: "Linked List", Difficulty: "easy", Description: "Find the middle node with fast and slow pointers."}, + {ID: "stv-9", Title: "Merge Sort", Topic: "Sorting", Difficulty: "medium", Description: "Implement divide-and-conquer merge sort."}, + {ID: "stv-10", Title: "Quick Sort", Topic: "Sorting", Difficulty: "medium", Description: "Partition and sort recursively using quick sort."}, + {ID: "stv-11", Title: "Implement Stack using Queue", Topic: "Stack/Queue", Difficulty: "easy", Description: "Simulate stack operations with queue primitives."}, + {ID: "stv-12", Title: "Sliding Window Maximum", Topic: "Sliding Window", Difficulty: "hard", Description: "Track maximum values inside a moving window."}, + {ID: "stv-13", Title: "Inorder Traversal", Topic: "Trees", Difficulty: "easy", Description: "Traverse a binary tree in inorder sequence."}, + {ID: "stv-14", Title: "Diameter of Binary Tree", Topic: "Trees", Difficulty: "medium", Description: "Compute the longest path through a binary tree."}, + {ID: "stv-15", Title: "Number of Islands", Topic: "Graphs", Difficulty: "medium", Description: "Count connected land components in a grid."}, + }, + }, +} + +var orderedSheetKeys = []string{ + "gfg-dsa-360", + "strivers-dsa-sheet", +} + +func listSheets() []SheetData { + sheets := make([]SheetData, 0, len(orderedSheetKeys)) + for _, key := range orderedSheetKeys { + sheets = append(sheets, sheetToData(catalogs[key])) + } + return sheets +} + +func sheetToData(catalog sheetCatalog) SheetData { + questions := make([]SheetQuestionData, 0, len(catalog.Questions)) + for _, question := range catalog.Questions { + questions = append(questions, SheetQuestionData{ + ID: question.ID, + Title: question.Title, + Topic: question.Topic, + Difficulty: question.Difficulty, + }) + } + + return SheetData{ + Key: catalog.Key, + Name: catalog.Name, + Questions: questions, + } +} + +func findSheet(key string) (sheetCatalog, bool) { + catalog, ok := catalogs[key] + return catalog, ok +} + +func findSheetQuestion(sheetKey, questionID string) (sheetQuestion, bool) { + catalog, ok := catalogs[sheetKey] + if !ok { + return sheetQuestion{}, false + } + + for _, question := range catalog.Questions { + if question.ID == questionID { + return question, true + } + } + + return sheetQuestion{}, false +} + +func catalogLink(sheetKey, questionID string) string { + return "app-sheet:" + sheetKey + ":" + questionID +} + +func findSheetQuestionByLink(link string) (sheetQuestion, bool) { + if !strings.HasPrefix(link, "app-sheet:") { + return sheetQuestion{}, false + } + + parts := strings.Split(link, ":") + if len(parts) != 3 { + return sheetQuestion{}, false + } + + return findSheetQuestion(parts[1], parts[2]) +} diff --git a/apps/server/internal/modules/app/data_test.go b/apps/server/internal/modules/app/data_test.go new file mode 100644 index 0000000..a514a1a --- /dev/null +++ b/apps/server/internal/modules/app/data_test.go @@ -0,0 +1,54 @@ +package app + +import "testing" + +func TestListSheetsPreservesSupportedOrder(t *testing.T) { + sheets := listSheets() + + if len(sheets) != len(orderedSheetKeys) { + t.Fatalf("expected %d sheets, got %d", len(orderedSheetKeys), len(sheets)) + } + + for index, key := range orderedSheetKeys { + if sheets[index].Key != key { + t.Fatalf("expected sheet %d to be %q, got %q", index, key, sheets[index].Key) + } + if len(sheets[index].Questions) == 0 { + t.Fatalf("expected sheet %q to expose questions", key) + } + } +} + +func TestFindSheetQuestionByLinkRoundTrip(t *testing.T) { + link := catalogLink("gfg-dsa-360", "gfg-1") + + question, ok := findSheetQuestionByLink(link) + if !ok { + t.Fatalf("expected link %q to resolve", link) + } + + if question.Title != "Array Rotation" { + t.Fatalf("expected resolved question title %q, got %q", "Array Rotation", question.Title) + } + if question.Topic != "Arrays" { + t.Fatalf("expected resolved question topic %q, got %q", "Arrays", question.Topic) + } +} + +func TestFindSheetQuestionByLinkRejectsUnknownLinks(t *testing.T) { + tests := []string{ + "", + "https://example.com", + "app-sheet:missing-parts", + "app-sheet:unknown-sheet:gfg-1", + "app-sheet:gfg-dsa-360:missing-question", + } + + for _, link := range tests { + t.Run(link, func(t *testing.T) { + if _, ok := findSheetQuestionByLink(link); ok { + t.Fatalf("expected link %q to be rejected", link) + } + }) + } +} diff --git a/apps/server/internal/modules/app/dto.go b/apps/server/internal/modules/app/dto.go new file mode 100644 index 0000000..57337c0 --- /dev/null +++ b/apps/server/internal/modules/app/dto.go @@ -0,0 +1,166 @@ +package app + +type UserData struct { + ID string `json:"id"` + Name string `json:"name"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Username string `json:"username"` + Email string `json:"email"` + Bio string `json:"bio,omitempty"` + Github string `json:"github,omitempty"` + Linkedin string `json:"linkedin,omitempty"` +} + +type OrganizationData struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +type BootcampData struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type EnrollmentData struct { + ID string `json:"id,omitempty"` + AssignedSheet string `json:"assignedSheet,omitempty"` +} + +type ContextData struct { + Role string `json:"role"` + AccountStatus string `json:"accountStatus"` + User UserData `json:"user"` + Organization *OrganizationData `json:"organization,omitempty"` + Bootcamp *BootcampData `json:"bootcamp,omitempty"` + Enrollment *EnrollmentData `json:"enrollment,omitempty"` +} + +type MenteeSignupRequest struct { + FirstName string `json:"firstName" validate:"required,min=2,max=50"` + LastName string `json:"lastName" validate:"omitempty,max=50"` + Username string `json:"username" validate:"required,min=3,max=80"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=8,max=50,password_complexity"` +} + +type MenteeSignupData struct { + RequestID string `json:"requestId"` + Status string `json:"status"` + Username string `json:"username"` + Email string `json:"email"` +} + +type MenteeRequestData struct { + ID string `json:"id"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Username string `json:"username"` + Email string `json:"email"` + SignedUpAt string `json:"signedUpAt"` + Status string `json:"status"` + AssignedSheet string `json:"assignedSheet,omitempty"` +} + +type ReviewMenteeRequest struct { + Status string `json:"status" validate:"required,oneof=approved rejected"` + SheetKey string `json:"sheetKey" validate:"omitempty,oneof=gfg-dsa-360 strivers-dsa-sheet"` +} + +type SheetQuestionData struct { + ID string `json:"id"` + Title string `json:"title"` + Topic string `json:"topic"` + Difficulty string `json:"difficulty"` +} + +type SheetData struct { + Key string `json:"key"` + Name string `json:"name"` + Questions []SheetQuestionData `json:"questions"` +} + +type DayAssignmentMenteeData struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Username string `json:"username"` + Email string `json:"email"` + Assigned bool `json:"assigned"` + AssignedSheet string `json:"assignedSheet,omitempty"` +} + +type DayAssignmentsData struct { + Day string `json:"day"` + Mentees []DayAssignmentMenteeData `json:"mentees"` +} + +type UpdateDayAssignmentsRequest struct { + Usernames []string `json:"usernames" validate:"required,min=0,dive,min=3,max=80"` +} + +type CreateAssignmentsRequest struct { + Day string `json:"day" validate:"omitempty,oneof=monday tuesday wednesday thursday friday saturday sunday"` + MenteeUsernames []string `json:"menteeUsernames" validate:"required,min=1,dive,min=3,max=80"` + SheetKey string `json:"sheetKey" validate:"required,oneof=gfg-dsa-360 strivers-dsa-sheet"` + QuestionIDs []string `json:"questionIds" validate:"required,min=1,dive,min=1,max=64"` +} + +type CreateAssignmentsData struct { + AssignmentGroupID string `json:"assignmentGroupId"` + AssignmentsCount int `json:"assignmentsCount"` +} + +type QuestionData struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Difficulty string `json:"difficulty"` + Topic string `json:"topic"` + Status string `json:"status"` + ProgressStatus string `json:"progressStatus"` + AssignedAt string `json:"assignedAt"` + CompletedAt string `json:"completedAt,omitempty"` + Solution string `json:"solution,omitempty"` + Resources string `json:"resources,omitempty"` +} + +type UpdateQuestionRequest struct { + ProgressStatus *string `json:"progressStatus" validate:"omitempty,oneof=not_started discussion_needed revision_needed completed"` + Solution *string `json:"solution" validate:"omitempty,max=2000"` + Resources *string `json:"resources" validate:"omitempty,max=2000"` +} + +type ProfileData struct { + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Username string `json:"username"` + Email string `json:"email"` + Solved int `json:"solved"` + JoinedAt string `json:"joinedAt"` + Bio string `json:"bio,omitempty"` + Github string `json:"github,omitempty"` + Linkedin string `json:"linkedin,omitempty"` +} + +type UpdateProfileRequest struct { + FirstName string `json:"firstName" validate:"required,min=2,max=50"` + LastName string `json:"lastName" validate:"omitempty,max=50"` + Username string `json:"username" validate:"required,min=3,max=80"` + Email string `json:"email" validate:"required,email"` + Bio string `json:"bio" validate:"omitempty,max=500"` + Github string `json:"github" validate:"omitempty,url"` + Linkedin string `json:"linkedin" validate:"omitempty,url"` +} + +type UpdatePasswordRequest struct { + CurrentPassword string `json:"currentPassword" validate:"required,min=8,max=50"` + NewPassword string `json:"newPassword" validate:"required,min=8,max=50,password_complexity"` +} + +type LeaderboardEntryData struct { + Username string `json:"username"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Solved int `json:"solved"` +} diff --git a/apps/server/internal/modules/app/handler.go b/apps/server/internal/modules/app/handler.go new file mode 100644 index 0000000..a918a9b --- /dev/null +++ b/apps/server/internal/modules/app/handler.go @@ -0,0 +1,310 @@ +package app + +import ( + "net/http" + + authmw "github.com/coderz-space/coderz.space/internal/common/middleware/auth" + "github.com/coderz-space/coderz.space/internal/common/response" + "github.com/coderz-space/coderz.space/internal/common/utils" + "github.com/coderz-space/coderz.space/internal/common/validator" + "github.com/labstack/echo/v5" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +func (h *Handler) GetContext(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + data, err := h.service.GetContext(c.Request().Context(), userID) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "APP_CONTEXT_RETRIEVED", data, nil) +} + +func (h *Handler) MenteeSignup(c *echo.Context) error { + var body MenteeSignupRequest + if err := (&echo.DefaultBinder{}).Bind(c, &body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", "INVALID_REQUEST_BODY", nil, err) + } + if err := validator.NewValidator().ValidateStruct(body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "VALIDATION_FAILED", nil, err) + } + + data, err := h.service.MenteeSignup(c.Request().Context(), body) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusCreated, "CREATED", "MENTEE_SIGNUP_REQUEST_CREATED", data, nil) +} + +func (h *Handler) ListMenteeRequests(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + data, err := h.service.ListMenteeRequests(c.Request().Context(), userID) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "MENTEE_REQUESTS_RETRIEVED", data, nil) +} + +func (h *Handler) ReviewMenteeRequest(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + var body ReviewMenteeRequest + if err := (&echo.DefaultBinder{}).Bind(c, &body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", "INVALID_REQUEST_BODY", nil, err) + } + if err := validator.NewValidator().ValidateStruct(body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "VALIDATION_FAILED", nil, err) + } + + data, err := h.service.ReviewMenteeRequest(c.Request().Context(), userID, (*c).Param("requestId"), body) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "MENTEE_REQUEST_UPDATED", data, nil) +} + +func (h *Handler) ListSheets(c *echo.Context) error { + return response.NewResponse(c, http.StatusOK, "OK", "SHEETS_RETRIEVED", h.service.ListSheets(), nil) +} + +func (h *Handler) GetDayAssignments(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + data, err := h.service.GetDayAssignments(c.Request().Context(), userID, (*c).Param("day")) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "DAY_ASSIGNMENTS_RETRIEVED", data, nil) +} + +func (h *Handler) UpdateDayAssignments(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + var body UpdateDayAssignmentsRequest + if err := (&echo.DefaultBinder{}).Bind(c, &body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", "INVALID_REQUEST_BODY", nil, err) + } + if err := validator.NewValidator().ValidateStruct(body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "VALIDATION_FAILED", nil, err) + } + + data, err := h.service.UpdateDayAssignments(c.Request().Context(), userID, (*c).Param("day"), body) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "DAY_ASSIGNMENTS_UPDATED", data, nil) +} + +func (h *Handler) CreateAssignments(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + var body CreateAssignmentsRequest + if err := (&echo.DefaultBinder{}).Bind(c, &body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", "INVALID_REQUEST_BODY", nil, err) + } + if err := validator.NewValidator().ValidateStruct(body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "VALIDATION_FAILED", nil, err) + } + + data, err := h.service.CreateAssignments(c.Request().Context(), userID, body) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusCreated, "CREATED", "ASSIGNMENTS_CREATED", data, nil) +} + +func (h *Handler) ListMenteeQuestions(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + data, err := h.service.ListMenteeQuestions(c.Request().Context(), userID, (*c).Param("username")) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "QUESTIONS_RETRIEVED", data, nil) +} + +func (h *Handler) GetMenteeQuestion(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + data, err := h.service.GetMenteeQuestion(c.Request().Context(), userID, (*c).Param("username"), (*c).Param("assignmentProblemId")) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "QUESTION_RETRIEVED", data, nil) +} + +func (h *Handler) UpdateMenteeQuestion(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + var body UpdateQuestionRequest + if err := (&echo.DefaultBinder{}).Bind(c, &body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", "INVALID_REQUEST_BODY", nil, err) + } + if err := validator.NewValidator().ValidateStruct(body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "VALIDATION_FAILED", nil, err) + } + + data, err := h.service.UpdateMenteeQuestion(c.Request().Context(), userID, (*c).Param("username"), (*c).Param("assignmentProblemId"), body) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "QUESTION_UPDATED", data, nil) +} + +func (h *Handler) GetMenteeProfile(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + data, err := h.service.GetMenteeProfile(c.Request().Context(), userID, (*c).Param("username")) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "PROFILE_RETRIEVED", data, nil) +} + +func (h *Handler) GetMyProfile(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + data, err := h.service.GetMyProfile(c.Request().Context(), userID) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "PROFILE_RETRIEVED", data, nil) +} + +func (h *Handler) UpdateMyProfile(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + var body UpdateProfileRequest + if err := (&echo.DefaultBinder{}).Bind(c, &body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", "INVALID_REQUEST_BODY", nil, err) + } + if err := validator.NewValidator().ValidateStruct(body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "VALIDATION_FAILED", nil, err) + } + + data, err := h.service.UpdateMyProfile(c.Request().Context(), userID, body) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "PROFILE_UPDATED", data, nil) +} + +func (h *Handler) UpdateMyPassword(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + var body UpdatePasswordRequest + if err := (&echo.DefaultBinder{}).Bind(c, &body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", "INVALID_REQUEST_BODY", nil, err) + } + if err := validator.NewValidator().ValidateStruct(body); err != nil { + return response.NewResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "VALIDATION_FAILED", nil, err) + } + + if err := h.service.UpdateMyPassword(c.Request().Context(), userID, body); err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "PASSWORD_UPDATED", map[string]any{}, nil) +} + +func (h *Handler) GetLeaderboard(c *echo.Context) error { + userID, err := currentUserID(c) + if err != nil { + return unauthorizedResponse(c, err) + } + + data, err := h.service.GetLeaderboard(c.Request().Context(), userID) + if err != nil { + return handleAppError(c, err) + } + + return response.NewResponse(c, http.StatusOK, "OK", "LEADERBOARD_RETRIEVED", data, nil) +} + +func currentUserID(c *echo.Context) (string, error) { + claims, ok := (*c).Get(authmw.ClaimsKey).(*utils.TokenPayload) + if !ok { + return "", echo.NewHTTPError(http.StatusUnauthorized, "INVALID_TOKEN_CLAIMS") + } + return claims.UserID, nil +} + +func unauthorizedResponse(c *echo.Context, err error) error { + return response.NewResponse(c, http.StatusUnauthorized, "UNAUTHORIZED", err.Error(), nil, nil) +} + +func handleAppError(c *echo.Context, err error) error { + switch err.Error() { + case "ACCESS_DENIED": + return response.NewResponse(c, http.StatusForbidden, "FORBIDDEN", "ACCESS_DENIED", nil, nil) + case "USER_NOT_FOUND", "MENTEE_NOT_FOUND", "QUESTION_NOT_FOUND", "REQUEST_NOT_FOUND", "SHEET_NOT_FOUND": + return response.NewResponse(c, http.StatusNotFound, "NOT_FOUND", err.Error(), nil, nil) + case "EMAIL_ALREADY_EXISTS", "USERNAME_ALREADY_EXISTS": + return response.NewResponse(c, http.StatusConflict, "CONFLICT", err.Error(), nil, nil) + case "INVALID_USERNAME", "PASSWORD_MUST_CONTAIN_LETTER_AND_NUMBER", "SHEET_REQUIRED", "QUESTION_IDS_REQUIRED", "MENTEES_REQUIRED", "NO_FIELDS_TO_UPDATE", "BOOTCAMP_NOT_CONFIGURED", "INVALID_CURRENT_PASSWORD", "PASSWORD_LOGIN_NOT_AVAILABLE": + return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", err.Error(), nil, nil) + default: + return response.NewResponse(c, http.StatusInternalServerError, "INTERNAL_ERROR", err.Error(), nil, err) + } +} diff --git a/apps/server/internal/modules/app/handler_test.go b/apps/server/internal/modules/app/handler_test.go new file mode 100644 index 0000000..521f312 --- /dev/null +++ b/apps/server/internal/modules/app/handler_test.go @@ -0,0 +1,41 @@ +package app + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" +) + +func TestHandleAppErrorMapsKnownErrors(t *testing.T) { + tests := []struct { + name string + err error + wantStatus int + }{ + {name: "access denied", err: errors.New("ACCESS_DENIED"), wantStatus: http.StatusForbidden}, + {name: "not found", err: errors.New("QUESTION_NOT_FOUND"), wantStatus: http.StatusNotFound}, + {name: "conflict", err: errors.New("USERNAME_ALREADY_EXISTS"), wantStatus: http.StatusConflict}, + {name: "bad request", err: errors.New("INVALID_CURRENT_PASSWORD"), wantStatus: http.StatusBadRequest}, + {name: "internal", err: errors.New("SOMETHING_ELSE"), wantStatus: http.StatusInternalServerError}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/v1/app/context", nil) + rec := httptest.NewRecorder() + ctx := e.NewContext(req, rec) + + if err := handleAppError(ctx, tt.err); err != nil { + t.Fatalf("expected response to be written, got error %v", err) + } + + if rec.Code != tt.wantStatus { + t.Fatalf("expected status %d, got %d", tt.wantStatus, rec.Code) + } + }) + } +} diff --git a/apps/server/internal/modules/app/routes.go b/apps/server/internal/modules/app/routes.go new file mode 100644 index 0000000..87e1ea2 --- /dev/null +++ b/apps/server/internal/modules/app/routes.go @@ -0,0 +1,33 @@ +package app + +import ( + authmw "github.com/coderz-space/coderz.space/internal/common/middleware/auth" + "github.com/coderz-space/coderz.space/internal/config" + "github.com/labstack/echo/v5" +) + +func RegisterPublicRoutes(e *echo.Group, handler *Handler) { + appRouter := e.Group("/v1/app") + appRouter.POST("/auth/mentee-signup", handler.MenteeSignup) +} + +func RegisterProtectedRoutes(e *echo.Group, handler *Handler, config *config.Config) { + appRouter := e.Group("/v1/app") + appRouter.Use(authmw.AuthMiddleware(config.JWTSecret, config.JWTExpires)) + + appRouter.GET("/context", handler.GetContext) + appRouter.GET("/mentor/mentee-requests", handler.ListMenteeRequests) + appRouter.PATCH("/mentor/mentee-requests/:requestId", handler.ReviewMenteeRequest) + appRouter.GET("/sheets", handler.ListSheets) + appRouter.GET("/mentor/day-assignments/:day", handler.GetDayAssignments) + appRouter.PUT("/mentor/day-assignments/:day", handler.UpdateDayAssignments) + appRouter.POST("/mentor/assignments", handler.CreateAssignments) + appRouter.GET("/mentees/:username/questions", handler.ListMenteeQuestions) + appRouter.GET("/mentees/:username/questions/:assignmentProblemId", handler.GetMenteeQuestion) + appRouter.PATCH("/mentees/:username/questions/:assignmentProblemId", handler.UpdateMenteeQuestion) + appRouter.GET("/mentees/:username/profile", handler.GetMenteeProfile) + appRouter.GET("/me/profile", handler.GetMyProfile) + appRouter.PATCH("/me/profile", handler.UpdateMyProfile) + appRouter.PATCH("/me/password", handler.UpdateMyPassword) + appRouter.GET("/leaderboard", handler.GetLeaderboard) +} diff --git a/apps/server/internal/modules/app/service.go b/apps/server/internal/modules/app/service.go new file mode 100644 index 0000000..c395a84 --- /dev/null +++ b/apps/server/internal/modules/app/service.go @@ -0,0 +1,1859 @@ +package app + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "time" + + db "github.com/coderz-space/coderz.space/internal/db/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" +) + +var usernamePattern = regexp.MustCompile(`^[a-z0-9_]+$`) + +type Service struct { + pool *pgxpool.Pool +} + +type resolvedContext struct { + User UserData + UserID string + Organization *OrganizationData + Bootcamp *BootcampData + MemberID string + OrgRole string + EnrollmentID string + EnrollmentRole string + AssignedSheet string + Role string + AccountStatus string +} + +type menteeRecord struct { + EnrollmentID string + MemberID string + UserID string + FirstName string + LastName string + Username string + Email string + AssignedSheet string + EnrolledAt time.Time +} + +type questionRow struct { + ID string + AssignmentID string + TargetUsername string + Title string + Description string + Difficulty string + ExternalLink string + AppProgress string + LegacyStatus string + Notes string + Resources string + AssignedAt time.Time + CompletedAt pgtype.Timestamptz +} + +func NewService(pool *pgxpool.Pool) *Service { + return &Service{pool: pool} +} + +func (s *Service) GetContext(ctx context.Context, userID string) (*ContextData, error) { + resolved, err := s.resolveContext(ctx, userID) + if err != nil { + return nil, err + } + + data := &ContextData{ + Role: resolved.Role, + AccountStatus: resolved.AccountStatus, + User: resolved.User, + Organization: resolved.Organization, + Bootcamp: resolved.Bootcamp, + } + if resolved.EnrollmentID != "" || resolved.AssignedSheet != "" { + data.Enrollment = &EnrollmentData{ + ID: resolved.EnrollmentID, + AssignedSheet: resolved.AssignedSheet, + } + } + + return data, nil +} + +func (s *Service) MenteeSignup(ctx context.Context, req MenteeSignupRequest) (*MenteeSignupData, error) { + username := normalizeUsername(req.Username) + if !usernamePattern.MatchString(username) { + return nil, errors.New("INVALID_USERNAME") + } + if !validatePasswordComplexity(req.Password) { + return nil, errors.New("PASSWORD_MUST_CONTAIN_LETTER_AND_NUMBER") + } + + defaultOrg, defaultBootcamp, err := s.getDefaultSignupContext(ctx) + if err != nil { + return nil, err + } + + fullName := strings.TrimSpace(strings.TrimSpace(req.FirstName) + " " + strings.TrimSpace(req.LastName)) + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + var emailExists bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 + FROM coderz.users + WHERE LOWER(COALESCE(email, '')) = LOWER($1) + ) + `, req.Email).Scan(&emailExists); err != nil { + return nil, err + } + if emailExists { + return nil, errors.New("EMAIL_ALREADY_EXISTS") + } + + var usernameExists bool + if err := tx.QueryRow(ctx, ` + SELECT EXISTS( + SELECT 1 + FROM coderz.users + WHERE LOWER(username) = LOWER($1) + ) + `, username).Scan(&usernameExists); err != nil { + return nil, err + } + if usernameExists { + return nil, errors.New("USERNAME_ALREADY_EXISTS") + } + + var userIDValue string + if err := tx.QueryRow(ctx, ` + INSERT INTO coderz.users ( + name, + email, + password_hash, + role, + username + ) VALUES ( + $1, + $2, + $3, + 'user', + $4 + ) + RETURNING id::text + `, fullName, req.Email, string(hashedPassword), username).Scan(&userIDValue); err != nil { + return nil, err + } + + var requestID string + if err := tx.QueryRow(ctx, ` + INSERT INTO coderz.mentee_requests ( + user_id, + organization_id, + bootcamp_id, + status + ) VALUES ( + $1, + $2, + $3, + 'pending' + ) + RETURNING id::text + `, userIDValue, defaultOrg.ID, defaultBootcamp.ID).Scan(&requestID); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return &MenteeSignupData{ + RequestID: requestID, + Status: "pending", + Username: username, + Email: req.Email, + }, nil +} + +func (s *Service) ListMenteeRequests(ctx context.Context, userID string) ([]MenteeRequestData, error) { + resolved, err := s.resolveMentorContext(ctx, userID) + if err != nil { + return nil, err + } + + rows, err := s.pool.Query(ctx, ` + SELECT + mr.id::text, + u.name, + COALESCE(u.username, ''), + COALESCE(u.email, ''), + mr.created_at, + mr.status, + COALESCE(mr.sheet_key, '') + FROM coderz.mentee_requests mr + JOIN coderz.users u ON u.id = mr.user_id + WHERE mr.bootcamp_id = $1 + ORDER BY + CASE WHEN mr.status = 'pending' THEN 0 ELSE 1 END, + mr.created_at DESC + `, resolved.Bootcamp.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + requests := make([]MenteeRequestData, 0) + for rows.Next() { + var ( + requestID string + fullName string + username string + email string + signedUpAt time.Time + status string + assignedSheet string + ) + if err := rows.Scan(&requestID, &fullName, &username, &email, &signedUpAt, &status, &assignedSheet); err != nil { + return nil, err + } + + firstName, lastName := splitName(fullName) + requests = append(requests, MenteeRequestData{ + ID: requestID, + FirstName: firstName, + LastName: lastName, + Username: username, + Email: email, + SignedUpAt: signedUpAt.Format(time.RFC3339), + Status: status, + AssignedSheet: assignedSheet, + }) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return requests, nil +} + +func (s *Service) ReviewMenteeRequest(ctx context.Context, userID, requestID string, req ReviewMenteeRequest) (*MenteeRequestData, error) { + resolved, err := s.resolveMentorContext(ctx, userID) + if err != nil { + return nil, err + } + if req.Status == "approved" && req.SheetKey == "" { + return nil, errors.New("SHEET_REQUIRED") + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + var ( + targetUserID string + orgID string + bootcampID string + fullName string + username string + email string + createdAt time.Time + ) + if err := tx.QueryRow(ctx, ` + SELECT + mr.user_id::text, + mr.organization_id::text, + mr.bootcamp_id::text, + u.name, + COALESCE(u.username, ''), + COALESCE(u.email, ''), + mr.created_at + FROM coderz.mentee_requests mr + JOIN coderz.users u ON u.id = mr.user_id + WHERE mr.id = $1 + AND mr.bootcamp_id = $2 + `, requestID, resolved.Bootcamp.ID).Scan( + &targetUserID, + &orgID, + &bootcampID, + &fullName, + &username, + &email, + &createdAt, + ); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("REQUEST_NOT_FOUND") + } + return nil, err + } + + if req.Status == "approved" { + memberID, err := s.ensureOrganizationMember(ctx, tx, orgID, targetUserID) + if err != nil { + return nil, err + } + if err := s.ensureBootcampEnrollment(ctx, tx, bootcampID, memberID, req.SheetKey); err != nil { + return nil, err + } + } + + if _, err := tx.Exec(ctx, ` + UPDATE coderz.mentee_requests + SET + status = $2, + sheet_key = NULLIF($3, ''), + reviewed_by = $4, + reviewed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, requestID, req.Status, req.SheetKey, resolved.MemberID); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + firstName, lastName := splitName(fullName) + return &MenteeRequestData{ + ID: requestID, + FirstName: firstName, + LastName: lastName, + Username: username, + Email: email, + SignedUpAt: createdAt.Format(time.RFC3339), + Status: req.Status, + AssignedSheet: req.SheetKey, + }, nil +} + +func (s *Service) ListSheets() []SheetData { + return listSheets() +} + +func (s *Service) GetDayAssignments(ctx context.Context, userID, day string) (*DayAssignmentsData, error) { + resolved, err := s.resolveMentorContext(ctx, userID) + if err != nil { + return nil, err + } + + normalizedDay := normalizeDay(day) + rows, err := s.pool.Query(ctx, ` + SELECT + u.name, + COALESCE(u.username, ''), + COALESCE(u.email, ''), + COALESCE(be.assigned_sheet_key, ''), + (mda.id IS NOT NULL) AS assigned + FROM coderz.bootcamp_enrollments be + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.users u ON u.id = om.user_id + LEFT JOIN coderz.mentee_day_assignments mda + ON mda.bootcamp_enrollment_id = be.id + AND mda.weekday = $2 + WHERE be.bootcamp_id = $1 + AND be.role = 'mentee' + AND be.status = 'active' + ORDER BY u.name ASC + `, resolved.Bootcamp.ID, normalizedDay) + if err != nil { + return nil, err + } + defer rows.Close() + + mentees := make([]DayAssignmentMenteeData, 0) + for rows.Next() { + var ( + fullName string + username string + email string + assignedSheet string + assigned bool + ) + if err := rows.Scan(&fullName, &username, &email, &assignedSheet, &assigned); err != nil { + return nil, err + } + + firstName, lastName := splitName(fullName) + mentees = append(mentees, DayAssignmentMenteeData{ + FirstName: firstName, + LastName: lastName, + Username: username, + Email: email, + Assigned: assigned, + AssignedSheet: assignedSheet, + }) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return &DayAssignmentsData{ + Day: normalizedDay, + Mentees: mentees, + }, nil +} + +func (s *Service) UpdateDayAssignments(ctx context.Context, userID, day string, req UpdateDayAssignmentsRequest) (*DayAssignmentsData, error) { + resolved, err := s.resolveMentorContext(ctx, userID) + if err != nil { + return nil, err + } + + normalizedDay := normalizeDay(day) + targets := dedupeLower(req.Usernames) + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + enrollmentMap, err := s.listMenteeEnrollmentMap(ctx, tx, resolved.Bootcamp.ID) + if err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, ` + DELETE FROM coderz.mentee_day_assignments mda + USING coderz.bootcamp_enrollments be + WHERE mda.bootcamp_enrollment_id = be.id + AND be.bootcamp_id = $1 + AND mda.weekday = $2 + `, resolved.Bootcamp.ID, normalizedDay); err != nil { + return nil, err + } + + for _, username := range targets { + enrollmentID, ok := enrollmentMap[username] + if !ok { + return nil, errors.New("MENTEE_NOT_FOUND") + } + + if _, err := tx.Exec(ctx, ` + INSERT INTO coderz.mentee_day_assignments ( + bootcamp_enrollment_id, + weekday, + created_by + ) VALUES ( + $1, + $2, + $3 + ) + `, enrollmentID, normalizedDay, resolved.MemberID); err != nil { + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return s.GetDayAssignments(ctx, userID, normalizedDay) +} + +func (s *Service) CreateAssignments(ctx context.Context, userID string, req CreateAssignmentsRequest) (*CreateAssignmentsData, error) { + resolved, err := s.resolveMentorContext(ctx, userID) + if err != nil { + return nil, err + } + + catalog, ok := findSheet(req.SheetKey) + if !ok { + return nil, errors.New("SHEET_NOT_FOUND") + } + + questionIDs := dedupeStrings(req.QuestionIDs) + if len(questionIDs) == 0 { + return nil, errors.New("QUESTION_IDS_REQUIRED") + } + + selectedQuestions := make([]sheetQuestion, 0, len(questionIDs)) + for _, questionID := range questionIDs { + question, found := findSheetQuestion(req.SheetKey, questionID) + if !found { + return nil, errors.New("QUESTION_NOT_FOUND") + } + selectedQuestions = append(selectedQuestions, question) + } + + targetUsernames := dedupeLower(req.MenteeUsernames) + if len(targetUsernames) == 0 { + return nil, errors.New("MENTEES_REQUIRED") + } + + mentees, err := s.listMenteeRecords(ctx, resolved.Bootcamp.ID) + if err != nil { + return nil, err + } + menteeByUsername := make(map[string]menteeRecord, len(mentees)) + for _, mentee := range mentees { + menteeByUsername[strings.ToLower(mentee.Username)] = mentee + } + + targets := make([]menteeRecord, 0, len(targetUsernames)) + for _, username := range targetUsernames { + mentee, ok := menteeByUsername[username] + if !ok { + return nil, errors.New("MENTEE_NOT_FOUND") + } + targets = append(targets, mentee) + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + groupTitle := fmt.Sprintf("Algo Buddy %s", catalog.Name) + if req.Day != "" { + groupTitle = fmt.Sprintf("Algo Buddy %s %s", capitalizeWord(normalizeDay(req.Day)), catalog.Name) + } + + description := fmt.Sprintf("App assignment generated from %s", catalog.Name) + if req.Day != "" { + description = fmt.Sprintf("App assignment generated for %s from %s", normalizeDay(req.Day), catalog.Name) + } + + var assignmentGroupID string + if err := tx.QueryRow(ctx, ` + INSERT INTO coderz.assignment_groups ( + bootcamp_id, + created_by, + title, + description + ) VALUES ( + $1, + $2, + $3, + $4 + ) + RETURNING id::text + `, resolved.Bootcamp.ID, resolved.MemberID, groupTitle, description).Scan(&assignmentGroupID); err != nil { + return nil, err + } + + problemIDs := make([]string, 0, len(selectedQuestions)) + for index, question := range selectedQuestions { + problemID, err := s.getOrCreateProblem(ctx, tx, resolved.Organization.ID, resolved.MemberID, req.SheetKey, question) + if err != nil { + return nil, err + } + problemIDs = append(problemIDs, problemID) + + if _, err := tx.Exec(ctx, ` + INSERT INTO coderz.assignment_group_problems ( + assignment_group_id, + problem_id, + position + ) VALUES ( + $1, + $2, + $3 + ) + `, assignmentGroupID, problemID, index+1); err != nil { + return nil, err + } + } + + for _, mentee := range targets { + var assignmentID string + if err := tx.QueryRow(ctx, ` + INSERT INTO coderz.assignments ( + assignment_group_id, + bootcamp_enrollment_id, + assigned_by, + status + ) VALUES ( + $1, + $2, + $3, + 'active' + ) + RETURNING id::text + `, assignmentGroupID, mentee.EnrollmentID, resolved.MemberID).Scan(&assignmentID); err != nil { + return nil, err + } + + for _, problemID := range problemIDs { + if _, err := tx.Exec(ctx, ` + INSERT INTO coderz.assignment_problems ( + assignment_id, + problem_id, + status, + app_progress_status + ) VALUES ( + $1, + $2, + 'pending', + 'not_started' + ) + `, assignmentID, problemID); err != nil { + return nil, err + } + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + if err := s.refreshLeaderboard(ctx, s.pool, resolved.Bootcamp.ID); err != nil { + return nil, err + } + + return &CreateAssignmentsData{ + AssignmentGroupID: assignmentGroupID, + AssignmentsCount: len(targets), + }, nil +} + +func (s *Service) ListMenteeQuestions(ctx context.Context, userID, username string) ([]QuestionData, error) { + resolved, err := s.resolveContext(ctx, userID) + if err != nil { + return nil, err + } + if resolved.Bootcamp == nil || resolved.AccountStatus != "approved" { + return nil, errors.New("ACCESS_DENIED") + } + + rows, err := s.pool.Query(ctx, ` + SELECT + ap.id::text, + a.id::text, + COALESCE(u.username, ''), + p.title, + COALESCE(p.description, ''), + p.difficulty::text, + COALESCE(p.external_link, ''), + COALESCE(ap.app_progress_status, ''), + ap.status::text, + COALESCE(ap.notes, ''), + COALESCE(ap.resources, ''), + a.assigned_at, + ap.completed_at + FROM coderz.assignment_problems ap + JOIN coderz.assignments a ON a.id = ap.assignment_id AND a.archived_at IS NULL + JOIN coderz.problems p ON p.id = ap.problem_id + JOIN coderz.bootcamp_enrollments be ON be.id = a.bootcamp_enrollment_id + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.users u ON u.id = om.user_id + WHERE be.bootcamp_id = $1 + AND be.role = 'mentee' + AND be.status = 'active' + AND LOWER(COALESCE(u.username, '')) = LOWER($2) + ORDER BY a.assigned_at DESC, ap.created_at ASC + `, resolved.Bootcamp.ID, username) + if err != nil { + return nil, err + } + defer rows.Close() + + questions := make([]QuestionData, 0) + for rows.Next() { + row, err := scanQuestionRow(rows) + if err != nil { + return nil, err + } + questions = append(questions, row.toQuestionData()) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return questions, nil +} + +func (s *Service) GetMenteeQuestion(ctx context.Context, userID, username, assignmentProblemID string) (*QuestionData, error) { + resolved, err := s.resolveContext(ctx, userID) + if err != nil { + return nil, err + } + if resolved.Bootcamp == nil || resolved.AccountStatus != "approved" { + return nil, errors.New("ACCESS_DENIED") + } + + row, err := s.getQuestionRow(ctx, s.pool, resolved.Bootcamp.ID, username, assignmentProblemID) + if err != nil { + return nil, err + } + + data := row.toQuestionData() + return &data, nil +} + +func (s *Service) UpdateMenteeQuestion(ctx context.Context, userID, username, assignmentProblemID string, req UpdateQuestionRequest) (*QuestionData, error) { + resolved, err := s.resolveContext(ctx, userID) + if err != nil { + return nil, err + } + if resolved.Bootcamp == nil || resolved.AccountStatus != "approved" { + return nil, errors.New("ACCESS_DENIED") + } + if resolved.Role != "mentor" && !strings.EqualFold(resolved.User.Username, username) { + return nil, errors.New("ACCESS_DENIED") + } + if req.ProgressStatus == nil && req.Solution == nil && req.Resources == nil { + return nil, errors.New("NO_FIELDS_TO_UPDATE") + } + + currentRow, err := s.getQuestionRow(ctx, s.pool, resolved.Bootcamp.ID, username, assignmentProblemID) + if err != nil { + return nil, err + } + + progressStatus := currentRow.normalizedProgressStatus() + if req.ProgressStatus != nil { + progressStatus = *req.ProgressStatus + } + + legacyStatus := mapProgressToLegacyStatus(progressStatus) + setCompletedAt := req.ProgressStatus != nil && progressStatus == "completed" + clearCompletedAt := req.ProgressStatus != nil && currentRow.normalizedProgressStatus() == "completed" && progressStatus != "completed" + + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + if _, err := tx.Exec(ctx, ` + UPDATE coderz.assignment_problems + SET + app_progress_status = $2, + status = $3, + notes = CASE WHEN $4 THEN $5 ELSE notes END, + resources = CASE WHEN $6 THEN $7 ELSE resources END, + completed_at = CASE + WHEN $8 THEN CURRENT_TIMESTAMP + WHEN $9 THEN NULL + ELSE completed_at + END, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, assignmentProblemID, progressStatus, legacyStatus, req.Solution != nil, valueOrEmpty(req.Solution), req.Resources != nil, valueOrEmpty(req.Resources), setCompletedAt, clearCompletedAt); err != nil { + return nil, err + } + + if err := s.updateAssignmentAggregate(ctx, tx, currentRow.AssignmentID); err != nil { + return nil, err + } + if err := s.refreshLeaderboard(ctx, tx, resolved.Bootcamp.ID); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + updatedRow, err := s.getQuestionRow(ctx, s.pool, resolved.Bootcamp.ID, username, assignmentProblemID) + if err != nil { + return nil, err + } + + data := updatedRow.toQuestionData() + return &data, nil +} + +func (s *Service) GetMenteeProfile(ctx context.Context, userID, username string) (*ProfileData, error) { + resolved, err := s.resolveContext(ctx, userID) + if err != nil { + return nil, err + } + if resolved.Bootcamp == nil || resolved.AccountStatus != "approved" { + return nil, errors.New("ACCESS_DENIED") + } + + mentee, err := s.findMenteeByUsername(ctx, s.pool, resolved.Bootcamp.ID, username) + if err != nil { + return nil, err + } + + solved, err := s.countCompletedProblems(ctx, resolved.Bootcamp.ID, username) + if err != nil { + return nil, err + } + + var ( + bio string + github string + linkedin string + ) + if err := s.pool.QueryRow(ctx, ` + SELECT + COALESCE(bio, ''), + COALESCE(github_url, ''), + COALESCE(linkedin_url, '') + FROM coderz.users + WHERE id = $1 + `, mentee.UserID).Scan(&bio, &github, &linkedin); err != nil { + return nil, err + } + + return &ProfileData{ + FirstName: mentee.FirstName, + LastName: mentee.LastName, + Username: mentee.Username, + Email: "", + Solved: solved, + JoinedAt: mentee.EnrolledAt.Format(time.RFC3339), + Bio: bio, + Github: github, + Linkedin: linkedin, + }, nil +} + +func (s *Service) GetMyProfile(ctx context.Context, userID string) (*ProfileData, error) { + resolved, err := s.resolveContext(ctx, userID) + if err != nil { + return nil, err + } + + solved := 0 + if resolved.Bootcamp != nil { + solved, err = s.countCompletedProblems(ctx, resolved.Bootcamp.ID, resolved.User.Username) + if err != nil { + return nil, err + } + } + + var createdAt time.Time + if err := s.pool.QueryRow(ctx, ` + SELECT created_at + FROM coderz.users + WHERE id = $1 + `, resolved.UserID).Scan(&createdAt); err != nil { + return nil, err + } + joinedAt := createdAt.Format(time.RFC3339) + + if resolved.EnrollmentID != "" { + var enrolledAt time.Time + if err := s.pool.QueryRow(ctx, ` + SELECT enrolled_at + FROM coderz.bootcamp_enrollments + WHERE id = $1 + `, resolved.EnrollmentID).Scan(&enrolledAt); err == nil { + joinedAt = enrolledAt.Format(time.RFC3339) + } + } + + return &ProfileData{ + FirstName: resolved.User.FirstName, + LastName: resolved.User.LastName, + Username: resolved.User.Username, + Email: resolved.User.Email, + Solved: solved, + JoinedAt: joinedAt, + Bio: resolved.User.Bio, + Github: resolved.User.Github, + Linkedin: resolved.User.Linkedin, + }, nil +} + +func (s *Service) UpdateMyProfile(ctx context.Context, userID string, req UpdateProfileRequest) (*ProfileData, error) { + resolved, err := s.resolveContext(ctx, userID) + if err != nil { + return nil, err + } + + username := normalizeUsername(req.Username) + if !usernamePattern.MatchString(username) { + return nil, errors.New("INVALID_USERNAME") + } + + fullName := strings.TrimSpace(strings.TrimSpace(req.FirstName) + " " + strings.TrimSpace(req.LastName)) + if _, err := s.pool.Exec(ctx, ` + UPDATE coderz.users + SET + name = $2, + email = $3, + username = $4, + bio = NULLIF($5, ''), + github_url = NULLIF($6, ''), + linkedin_url = NULLIF($7, ''), + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, resolved.UserID, fullName, req.Email, username, req.Bio, req.Github, req.Linkedin); err != nil { + lowerErr := strings.ToLower(err.Error()) + if strings.Contains(lowerErr, "uq_users_username") { + return nil, errors.New("USERNAME_ALREADY_EXISTS") + } + if strings.Contains(lowerErr, "users_email_key") { + return nil, errors.New("EMAIL_ALREADY_EXISTS") + } + return nil, err + } + + return s.GetMyProfile(ctx, userID) +} + +func (s *Service) UpdateMyPassword(ctx context.Context, userID string, req UpdatePasswordRequest) error { + if !validatePasswordComplexity(req.NewPassword) { + return errors.New("PASSWORD_MUST_CONTAIN_LETTER_AND_NUMBER") + } + + var passwordHash pgtype.Text + if err := s.pool.QueryRow(ctx, ` + SELECT password_hash + FROM coderz.users + WHERE id = $1 + `, userID).Scan(&passwordHash); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return errors.New("USER_NOT_FOUND") + } + return err + } + if !passwordHash.Valid { + return errors.New("PASSWORD_LOGIN_NOT_AVAILABLE") + } + if err := bcrypt.CompareHashAndPassword([]byte(passwordHash.String), []byte(req.CurrentPassword)); err != nil { + return errors.New("INVALID_CURRENT_PASSWORD") + } + + newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + if _, err := tx.Exec(ctx, ` + UPDATE coderz.users + SET + password_hash = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, userID, string(newHash)); err != nil { + return err + } + if _, err := tx.Exec(ctx, ` + DELETE FROM coderz.refresh_tokens + WHERE user_id = $1 + `, userID); err != nil { + return err + } + + return tx.Commit(ctx) +} + +func (s *Service) GetLeaderboard(ctx context.Context, userID string) ([]LeaderboardEntryData, error) { + resolved, err := s.resolveContext(ctx, userID) + if err != nil { + return nil, err + } + if resolved.Bootcamp == nil || resolved.AccountStatus != "approved" { + return nil, errors.New("ACCESS_DENIED") + } + + if err := s.refreshLeaderboard(ctx, s.pool, resolved.Bootcamp.ID); err != nil { + return nil, err + } + + rows, err := s.pool.Query(ctx, ` + SELECT + COALESCE(u.username, ''), + u.name, + COALESCE(le.problems_completed, 0) AS solved + FROM coderz.bootcamp_enrollments be + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.users u ON u.id = om.user_id + LEFT JOIN coderz.leaderboard_entries le + ON le.bootcamp_enrollment_id = be.id + AND le.bootcamp_id = be.bootcamp_id + WHERE be.bootcamp_id = $1 + AND be.role = 'mentee' + AND be.status = 'active' + ORDER BY COALESCE(le.rank, 2147483647), solved DESC, u.name ASC + `, resolved.Bootcamp.ID) + if err != nil { + return nil, err + } + defer rows.Close() + + entries := make([]LeaderboardEntryData, 0) + for rows.Next() { + var ( + username string + fullName string + solved int + ) + if err := rows.Scan(&username, &fullName, &solved); err != nil { + return nil, err + } + + firstName, lastName := splitName(fullName) + entries = append(entries, LeaderboardEntryData{ + Username: username, + FirstName: firstName, + LastName: lastName, + Solved: solved, + }) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return entries, nil +} + +func (s *Service) resolveContext(ctx context.Context, userID string) (*resolvedContext, error) { + var ( + foundID string + name string + username string + email string + bio string + github string + linkedin string + ) + if err := s.pool.QueryRow(ctx, ` + SELECT + id::text, + name, + COALESCE(username, ''), + COALESCE(email, ''), + COALESCE(bio, ''), + COALESCE(github_url, ''), + COALESCE(linkedin_url, '') + FROM coderz.users + WHERE id = $1 + `, userID).Scan(&foundID, &name, &username, &email, &bio, &github, &linkedin); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("USER_NOT_FOUND") + } + return nil, err + } + + firstName, lastName := splitName(name) + resolved := &resolvedContext{ + UserID: foundID, + User: UserData{ + ID: foundID, + Name: name, + FirstName: firstName, + LastName: lastName, + Username: username, + Email: email, + Bio: bio, + Github: github, + Linkedin: linkedin, + }, + Role: "unknown", + AccountStatus: "unassigned", + } + + var ( + memberID string + orgRole string + orgID string + orgName string + orgSlug string + bootcampID string + bootcampName string + enrollmentID string + enrollmentRole string + assignedSheet string + ) + err := s.pool.QueryRow(ctx, ` + SELECT + om.id::text, + om.role::text, + o.id::text, + o.name, + o.slug, + b.id::text, + b.name, + be.id::text, + be.role::text, + COALESCE(be.assigned_sheet_key, '') + FROM coderz.bootcamp_enrollments be + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.organizations o ON o.id = om.organization_id + JOIN coderz.bootcamps b ON b.id = be.bootcamp_id + WHERE om.user_id = $1 + AND o.status = 'approved' + AND b.archived_at IS NULL + AND b.is_active = TRUE + AND be.status = 'active' + ORDER BY b.created_at DESC, be.enrolled_at DESC + LIMIT 1 + `, userID).Scan(&memberID, &orgRole, &orgID, &orgName, &orgSlug, &bootcampID, &bootcampName, &enrollmentID, &enrollmentRole, &assignedSheet) + if err == nil { + resolved.MemberID = memberID + resolved.OrgRole = orgRole + resolved.Organization = &OrganizationData{ID: orgID, Name: orgName, Slug: orgSlug} + resolved.Bootcamp = &BootcampData{ID: bootcampID, Name: bootcampName} + resolved.EnrollmentID = enrollmentID + resolved.EnrollmentRole = enrollmentRole + resolved.AssignedSheet = assignedSheet + resolved.AccountStatus = "approved" + if enrollmentRole == "mentor" || orgRole == "admin" || orgRole == "mentor" { + resolved.Role = "mentor" + } else { + resolved.Role = "mentee" + } + return resolved, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + + err = s.pool.QueryRow(ctx, ` + SELECT + om.id::text, + om.role::text, + o.id::text, + o.name, + o.slug, + b.id::text, + b.name + FROM coderz.organization_members om + JOIN coderz.organizations o ON o.id = om.organization_id + JOIN coderz.bootcamps b ON b.organization_id = o.id + WHERE om.user_id = $1 + AND o.status = 'approved' + AND om.role IN ('admin', 'mentor') + AND b.archived_at IS NULL + AND b.is_active = TRUE + ORDER BY b.created_at DESC, om.joined_at DESC + LIMIT 1 + `, userID).Scan(&memberID, &orgRole, &orgID, &orgName, &orgSlug, &bootcampID, &bootcampName) + if err == nil { + resolved.MemberID = memberID + resolved.OrgRole = orgRole + resolved.Organization = &OrganizationData{ID: orgID, Name: orgName, Slug: orgSlug} + resolved.Bootcamp = &BootcampData{ID: bootcampID, Name: bootcampName} + resolved.Role = "mentor" + resolved.AccountStatus = "approved" + return resolved, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + + var status string + err = s.pool.QueryRow(ctx, ` + SELECT + mr.status, + COALESCE(mr.sheet_key, ''), + o.id::text, + o.name, + o.slug, + b.id::text, + b.name + FROM coderz.mentee_requests mr + JOIN coderz.organizations o ON o.id = mr.organization_id + JOIN coderz.bootcamps b ON b.id = mr.bootcamp_id + WHERE mr.user_id = $1 + ORDER BY mr.created_at DESC + LIMIT 1 + `, userID).Scan(&status, &assignedSheet, &orgID, &orgName, &orgSlug, &bootcampID, &bootcampName) + if err == nil { + resolved.Organization = &OrganizationData{ID: orgID, Name: orgName, Slug: orgSlug} + resolved.Bootcamp = &BootcampData{ID: bootcampID, Name: bootcampName} + resolved.Role = "mentee" + resolved.AccountStatus = status + resolved.AssignedSheet = assignedSheet + return resolved, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return nil, err + } + + return resolved, nil +} + +func (s *Service) resolveMentorContext(ctx context.Context, userID string) (*resolvedContext, error) { + resolved, err := s.resolveContext(ctx, userID) + if err != nil { + return nil, err + } + if resolved.Role != "mentor" || resolved.AccountStatus != "approved" || resolved.Organization == nil || resolved.Bootcamp == nil || resolved.MemberID == "" { + return nil, errors.New("ACCESS_DENIED") + } + return resolved, nil +} + +func (s *Service) getDefaultSignupContext(ctx context.Context) (*OrganizationData, *BootcampData, error) { + var ( + orgID string + orgName string + orgSlug string + bootcampID string + bootcampName string + ) + if err := s.pool.QueryRow(ctx, ` + SELECT + o.id::text, + o.name, + o.slug, + b.id::text, + b.name + FROM coderz.bootcamps b + JOIN coderz.organizations o ON o.id = b.organization_id + WHERE o.status = 'approved' + AND b.archived_at IS NULL + AND b.is_active = TRUE + ORDER BY b.created_at DESC + LIMIT 1 + `).Scan(&orgID, &orgName, &orgSlug, &bootcampID, &bootcampName); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil, errors.New("BOOTCAMP_NOT_CONFIGURED") + } + return nil, nil, err + } + + return &OrganizationData{ID: orgID, Name: orgName, Slug: orgSlug}, &BootcampData{ID: bootcampID, Name: bootcampName}, nil +} + +func (s *Service) ensureOrganizationMember(ctx context.Context, q db.DBTX, organizationID, userID string) (string, error) { + var memberID string + err := q.QueryRow(ctx, ` + SELECT id::text + FROM coderz.organization_members + WHERE organization_id = $1 + AND user_id = $2 + LIMIT 1 + `, organizationID, userID).Scan(&memberID) + if err == nil { + return memberID, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return "", err + } + + if err := q.QueryRow(ctx, ` + INSERT INTO coderz.organization_members ( + organization_id, + user_id, + role + ) VALUES ( + $1, + $2, + 'mentee' + ) + RETURNING id::text + `, organizationID, userID).Scan(&memberID); err != nil { + return "", err + } + + return memberID, nil +} + +func (s *Service) ensureBootcampEnrollment(ctx context.Context, q db.DBTX, bootcampID, memberID, assignedSheet string) error { + var enrollmentID string + err := q.QueryRow(ctx, ` + SELECT id::text + FROM coderz.bootcamp_enrollments + WHERE bootcamp_id = $1 + AND organization_member_id = $2 + LIMIT 1 + `, bootcampID, memberID).Scan(&enrollmentID) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return err + } + + if errors.Is(err, pgx.ErrNoRows) { + _, err = q.Exec(ctx, ` + INSERT INTO coderz.bootcamp_enrollments ( + bootcamp_id, + organization_member_id, + role, + status, + assigned_sheet_key + ) VALUES ( + $1, + $2, + 'mentee', + 'active', + NULLIF($3, '') + ) + `, bootcampID, memberID, assignedSheet) + return err + } + + _, err = q.Exec(ctx, ` + UPDATE coderz.bootcamp_enrollments + SET + role = 'mentee', + status = 'active', + assigned_sheet_key = NULLIF($2, '') + WHERE id = $1 + `, enrollmentID, assignedSheet) + return err +} + +func (s *Service) listMenteeEnrollmentMap(ctx context.Context, q db.DBTX, bootcampID string) (map[string]string, error) { + rows, err := q.Query(ctx, ` + SELECT + LOWER(COALESCE(u.username, '')) AS username, + be.id::text + FROM coderz.bootcamp_enrollments be + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.users u ON u.id = om.user_id + WHERE be.bootcamp_id = $1 + AND be.role = 'mentee' + AND be.status = 'active' + `, bootcampID) + if err != nil { + return nil, err + } + defer rows.Close() + + result := make(map[string]string) + for rows.Next() { + var username string + var enrollmentID string + if err := rows.Scan(&username, &enrollmentID); err != nil { + return nil, err + } + result[username] = enrollmentID + } + + return result, rows.Err() +} + +func (s *Service) listMenteeRecords(ctx context.Context, bootcampID string) ([]menteeRecord, error) { + return s.listMenteeRecordsWithQuery(ctx, s.pool, bootcampID) +} + +func (s *Service) listMenteeRecordsWithQuery(ctx context.Context, q db.DBTX, bootcampID string) ([]menteeRecord, error) { + rows, err := q.Query(ctx, ` + SELECT + be.id::text, + om.id::text, + u.id::text, + u.name, + COALESCE(u.username, ''), + COALESCE(u.email, ''), + COALESCE(be.assigned_sheet_key, ''), + be.enrolled_at + FROM coderz.bootcamp_enrollments be + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.users u ON u.id = om.user_id + WHERE be.bootcamp_id = $1 + AND be.role = 'mentee' + AND be.status = 'active' + ORDER BY u.name ASC + `, bootcampID) + if err != nil { + return nil, err + } + defer rows.Close() + + mentees := make([]menteeRecord, 0) + for rows.Next() { + var ( + enrollmentID string + memberID string + userID string + fullName string + username string + email string + assignedSheet string + enrolledAt time.Time + ) + if err := rows.Scan(&enrollmentID, &memberID, &userID, &fullName, &username, &email, &assignedSheet, &enrolledAt); err != nil { + return nil, err + } + + firstName, lastName := splitName(fullName) + mentees = append(mentees, menteeRecord{ + EnrollmentID: enrollmentID, + MemberID: memberID, + UserID: userID, + FirstName: firstName, + LastName: lastName, + Username: username, + Email: email, + AssignedSheet: assignedSheet, + EnrolledAt: enrolledAt, + }) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return mentees, nil +} + +func (s *Service) findMenteeByUsername(ctx context.Context, q db.DBTX, bootcampID, username string) (*menteeRecord, error) { + var ( + enrollmentID string + memberID string + userID string + fullName string + foundUsername string + email string + assignedSheet string + enrolledAt time.Time + ) + if err := q.QueryRow(ctx, ` + SELECT + be.id::text, + om.id::text, + u.id::text, + u.name, + COALESCE(u.username, ''), + COALESCE(u.email, ''), + COALESCE(be.assigned_sheet_key, ''), + be.enrolled_at + FROM coderz.bootcamp_enrollments be + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.users u ON u.id = om.user_id + WHERE be.bootcamp_id = $1 + AND be.role = 'mentee' + AND be.status = 'active' + AND LOWER(COALESCE(u.username, '')) = LOWER($2) + LIMIT 1 + `, bootcampID, username).Scan(&enrollmentID, &memberID, &userID, &fullName, &foundUsername, &email, &assignedSheet, &enrolledAt); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("MENTEE_NOT_FOUND") + } + return nil, err + } + + firstName, lastName := splitName(fullName) + return &menteeRecord{ + EnrollmentID: enrollmentID, + MemberID: memberID, + UserID: userID, + FirstName: firstName, + LastName: lastName, + Username: foundUsername, + Email: email, + AssignedSheet: assignedSheet, + EnrolledAt: enrolledAt, + }, nil +} + +func (s *Service) getOrCreateProblem(ctx context.Context, q db.DBTX, organizationID, createdBy, sheetKey string, question sheetQuestion) (string, error) { + link := catalogLink(sheetKey, question.ID) + var problemID string + err := q.QueryRow(ctx, ` + SELECT id::text + FROM coderz.problems + WHERE organization_id = $1 + AND external_link = $2 + LIMIT 1 + `, organizationID, link).Scan(&problemID) + if err == nil { + return problemID, nil + } + if !errors.Is(err, pgx.ErrNoRows) { + return "", err + } + + if err := q.QueryRow(ctx, ` + INSERT INTO coderz.problems ( + organization_id, + created_by, + title, + description, + difficulty, + external_link + ) VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 + ) + RETURNING id::text + `, organizationID, createdBy, question.Title, question.Description, question.Difficulty, link).Scan(&problemID); err != nil { + return "", err + } + + return problemID, nil +} + +func (s *Service) getQuestionRow(ctx context.Context, q db.DBTX, bootcampID, username, assignmentProblemID string) (*questionRow, error) { + row := q.QueryRow(ctx, ` + SELECT + ap.id::text, + a.id::text, + COALESCE(u.username, ''), + p.title, + COALESCE(p.description, ''), + p.difficulty::text, + COALESCE(p.external_link, ''), + COALESCE(ap.app_progress_status, ''), + ap.status::text, + COALESCE(ap.notes, ''), + COALESCE(ap.resources, ''), + a.assigned_at, + ap.completed_at + FROM coderz.assignment_problems ap + JOIN coderz.assignments a ON a.id = ap.assignment_id AND a.archived_at IS NULL + JOIN coderz.problems p ON p.id = ap.problem_id + JOIN coderz.bootcamp_enrollments be ON be.id = a.bootcamp_enrollment_id + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.users u ON u.id = om.user_id + WHERE ap.id = $1 + AND be.bootcamp_id = $2 + AND LOWER(COALESCE(u.username, '')) = LOWER($3) + LIMIT 1 + `, assignmentProblemID, bootcampID, username) + + question, err := scanQuestionRow(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errors.New("QUESTION_NOT_FOUND") + } + return nil, err + } + + return &question, nil +} + +func (s *Service) updateAssignmentAggregate(ctx context.Context, q db.DBTX, assignmentID string) error { + var totalCount int + var completedCount int + if err := q.QueryRow(ctx, ` + SELECT + COUNT(*)::int, + COUNT(*) FILTER (WHERE status = 'completed')::int + FROM coderz.assignment_problems + WHERE assignment_id = $1 + `, assignmentID).Scan(&totalCount, &completedCount); err != nil { + return err + } + + status := "active" + if totalCount > 0 && totalCount == completedCount { + status = "completed" + } + + _, err := q.Exec(ctx, ` + UPDATE coderz.assignments + SET + status = $2, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + `, assignmentID, status) + return err +} + +func (s *Service) refreshLeaderboard(ctx context.Context, q db.DBTX, bootcampID string) error { + rows, err := q.Query(ctx, ` + SELECT + be.id::text, + COUNT(ap.id)::int AS total_assigned, + COUNT(*) FILTER ( + WHERE ap.app_progress_status = 'completed' + OR ap.status = 'completed' + )::int AS completed_count, + COUNT(*) FILTER ( + WHERE ap.app_progress_status <> 'not_started' + OR ap.status IN ('attempted', 'completed') + )::int AS attempted_count + FROM coderz.bootcamp_enrollments be + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.users u ON u.id = om.user_id + LEFT JOIN coderz.assignments a + ON a.bootcamp_enrollment_id = be.id + AND a.archived_at IS NULL + LEFT JOIN coderz.assignment_problems ap ON ap.assignment_id = a.id + WHERE be.bootcamp_id = $1 + AND be.role = 'mentee' + AND be.status = 'active' + GROUP BY be.id, u.username, u.name + ORDER BY completed_count DESC, attempted_count DESC, u.name ASC + `, bootcampID) + if err != nil { + return err + } + defer rows.Close() + + type leaderboardRow struct { + enrollmentID string + total int + completed int + attempted int + } + stats := make([]leaderboardRow, 0) + for rows.Next() { + var item leaderboardRow + if err := rows.Scan(&item.enrollmentID, &item.total, &item.completed, &item.attempted); err != nil { + return err + } + stats = append(stats, item) + } + if err := rows.Err(); err != nil { + return err + } + + if _, err := q.Exec(ctx, ` + DELETE FROM coderz.leaderboard_entries + WHERE bootcamp_id = $1 + `, bootcampID); err != nil { + return err + } + + for index, item := range stats { + completionRate := float32(0) + if item.total > 0 { + completionRate = float32(item.completed) / float32(item.total) + } + score := (item.completed * 10) + (item.attempted * 3) + + if _, err := q.Exec(ctx, ` + INSERT INTO coderz.leaderboard_entries ( + bootcamp_id, + bootcamp_enrollment_id, + problems_completed, + problems_attempted, + completion_rate, + streak_days, + score, + rank, + calculated_at + ) VALUES ( + $1, + $2, + $3, + $4, + $5, + 0, + $6, + $7, + CURRENT_TIMESTAMP + ) + `, bootcampID, item.enrollmentID, item.completed, item.attempted, completionRate, score, index+1); err != nil { + return err + } + } + + return nil +} + +func (s *Service) countCompletedProblems(ctx context.Context, bootcampID, username string) (int, error) { + var solved int + if err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*)::int + FROM coderz.assignment_problems ap + JOIN coderz.assignments a ON a.id = ap.assignment_id AND a.archived_at IS NULL + JOIN coderz.bootcamp_enrollments be ON be.id = a.bootcamp_enrollment_id + JOIN coderz.organization_members om ON om.id = be.organization_member_id + JOIN coderz.users u ON u.id = om.user_id + WHERE be.bootcamp_id = $1 + AND LOWER(COALESCE(u.username, '')) = LOWER($2) + AND ( + ap.app_progress_status = 'completed' + OR ap.status = 'completed' + ) + `, bootcampID, username).Scan(&solved); err != nil { + return 0, err + } + return solved, nil +} + +func scanQuestionRow(scanner interface{ Scan(dest ...any) error }) (questionRow, error) { + var row questionRow + err := scanner.Scan( + &row.ID, + &row.AssignmentID, + &row.TargetUsername, + &row.Title, + &row.Description, + &row.Difficulty, + &row.ExternalLink, + &row.AppProgress, + &row.LegacyStatus, + &row.Notes, + &row.Resources, + &row.AssignedAt, + &row.CompletedAt, + ) + return row, err +} + +func (q questionRow) normalizedProgressStatus() string { + if q.AppProgress != "" { + return q.AppProgress + } + switch q.LegacyStatus { + case "completed": + return "completed" + case "attempted": + return "revision_needed" + default: + return "not_started" + } +} + +func (q questionRow) toQuestionData() QuestionData { + description := q.Description + topic := "General" + if catalogQuestion, ok := findSheetQuestionByLink(q.ExternalLink); ok { + description = catalogQuestion.Description + topic = catalogQuestion.Topic + } + + progressStatus := q.normalizedProgressStatus() + status := "pending" + if progressStatus == "completed" { + status = "completed" + } + + completedAt := "" + if q.CompletedAt.Valid { + completedAt = q.CompletedAt.Time.Format(time.RFC3339) + } + + return QuestionData{ + ID: q.ID, + Title: q.Title, + Description: description, + Difficulty: q.Difficulty, + Topic: topic, + Status: status, + ProgressStatus: progressStatus, + AssignedAt: q.AssignedAt.Format(time.RFC3339), + CompletedAt: completedAt, + Solution: q.Notes, + Resources: q.Resources, + } +} + +func splitName(name string) (string, string) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "", "" + } + parts := strings.Fields(trimmed) + if len(parts) == 1 { + return parts[0], "" + } + return parts[0], strings.Join(parts[1:], " ") +} + +func normalizeUsername(username string) string { + return strings.ToLower(strings.TrimSpace(username)) +} + +func validatePasswordComplexity(password string) bool { + hasLetter := false + hasNumber := false + + for _, char := range password { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') { + hasLetter = true + } + if char >= '0' && char <= '9' { + hasNumber = true + } + if hasLetter && hasNumber { + return true + } + } + + return false +} + +func dedupeLower(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + return result +} + +func dedupeStrings(values []string) []string { + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + normalized := strings.TrimSpace(value) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + return result +} + +func mapProgressToLegacyStatus(progress string) string { + switch progress { + case "completed": + return "completed" + case "discussion_needed", "revision_needed": + return "attempted" + default: + return "pending" + } +} + +func normalizeDay(day string) string { + return strings.ToLower(strings.TrimSpace(day)) +} + +func capitalizeWord(value string) string { + if value == "" { + return "" + } + return strings.ToUpper(value[:1]) + value[1:] +} + +func valueOrEmpty(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/apps/server/internal/modules/app/service_test.go b/apps/server/internal/modules/app/service_test.go new file mode 100644 index 0000000..f459610 --- /dev/null +++ b/apps/server/internal/modules/app/service_test.go @@ -0,0 +1,119 @@ +package app + +import ( + "testing" + "time" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestNormalizeUsername(t *testing.T) { + if got := normalizeUsername(" Alice_User "); got != "alice_user" { + t.Fatalf("expected normalized username %q, got %q", "alice_user", got) + } +} + +func TestValidatePasswordComplexity(t *testing.T) { + tests := []struct { + password string + valid bool + }{ + {password: "Password123", valid: true}, + {password: "lettersonly", valid: false}, + {password: "123456789", valid: false}, + {password: "Alpha9", valid: true}, + } + + for _, tt := range tests { + t.Run(tt.password, func(t *testing.T) { + if got := validatePasswordComplexity(tt.password); got != tt.valid { + t.Fatalf("expected complexity check for %q to be %v, got %v", tt.password, tt.valid, got) + } + }) + } +} + +func TestQuestionRowToQuestionDataUsesCatalogMetadata(t *testing.T) { + assignedAt := time.Date(2026, time.April, 1, 9, 0, 0, 0, time.UTC) + completedAt := time.Date(2026, time.April, 2, 9, 0, 0, 0, time.UTC) + + row := questionRow{ + ID: "assignment-problem-1", + Title: "Array Rotation", + Description: "database description", + Difficulty: "easy", + ExternalLink: catalogLink("gfg-dsa-360", "gfg-1"), + AppProgress: "completed", + Notes: "notes", + Resources: "resources", + AssignedAt: assignedAt, + CompletedAt: pgtype.Timestamptz{ + Time: completedAt, + Valid: true, + }, + } + + data := row.toQuestionData() + + if data.Description != "Practice array rotation techniques and in-place updates." { + t.Fatalf("expected catalog description, got %q", data.Description) + } + if data.Topic != "Arrays" { + t.Fatalf("expected catalog topic %q, got %q", "Arrays", data.Topic) + } + if data.Status != "completed" { + t.Fatalf("expected completed status, got %q", data.Status) + } + if data.CompletedAt != completedAt.Format(time.RFC3339) { + t.Fatalf("expected completedAt %q, got %q", completedAt.Format(time.RFC3339), data.CompletedAt) + } +} + +func TestQuestionRowToQuestionDataFallsBackToDatabaseFields(t *testing.T) { + assignedAt := time.Date(2026, time.April, 1, 9, 0, 0, 0, time.UTC) + row := questionRow{ + ID: "assignment-problem-2", + Title: "Custom Problem", + Description: "database description", + Difficulty: "medium", + AppProgress: "", + LegacyStatus: "attempted", + AssignedAt: assignedAt, + } + + data := row.toQuestionData() + + if data.Description != "database description" { + t.Fatalf("expected database description fallback, got %q", data.Description) + } + if data.Topic != "General" { + t.Fatalf("expected default topic %q, got %q", "General", data.Topic) + } + if data.ProgressStatus != "revision_needed" { + t.Fatalf("expected attempted legacy status to map to revision_needed, got %q", data.ProgressStatus) + } + if data.Status != "pending" { + t.Fatalf("expected non-completed question to stay pending, got %q", data.Status) + } +} + +func TestMapProgressToLegacyStatus(t *testing.T) { + tests := []struct { + progress string + expected string + }{ + {progress: "completed", expected: "completed"}, + {progress: "discussion_needed", expected: "attempted"}, + {progress: "revision_needed", expected: "attempted"}, + {progress: "not_started", expected: "pending"}, + {progress: "unexpected", expected: "pending"}, + } + + for _, tt := range tests { + t.Run(tt.progress, func(t *testing.T) { + if got := mapProgressToLegacyStatus(tt.progress); got != tt.expected { + t.Fatalf("expected legacy status %q, got %q", tt.expected, got) + } + }) + } +} diff --git a/apps/server/internal/modules/auth/handler.go b/apps/server/internal/modules/auth/handler.go index 90a7836..45f3071 100644 --- a/apps/server/internal/modules/auth/handler.go +++ b/apps/server/internal/modules/auth/handler.go @@ -1,7 +1,6 @@ package auth import ( - "fmt" "net/http" "github.com/coderz-space/coderz.space/internal/common/middleware/auth" @@ -36,20 +35,15 @@ func (h *Handler) Signup(c *echo.Context) error { if err := (&echo.DefaultBinder{}).Bind(c, &body); err != nil { return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", "INVALID_REQUEST_BODY", nil, err) } - fmt.Println("hello world😅 1") if err := validator.NewValidator().ValidateStruct(body); err != nil { - fmt.Println("hello world😅 2") - return response.NewResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", "VALIDATION_FAILED", nil, err) } - fmt.Println("hello world😅 x") data, err := h.service.Signup(c.Request().Context(), body) if err != nil { return response.NewResponse(c, http.StatusBadRequest, "BAD_REQUEST", err.Error(), nil, nil) } - fmt.Println("hello world😅 3") h.setAuthCookies(c, data.AccessToken, data.RefreshToken) @@ -240,12 +234,14 @@ func (h *Handler) ResetPassword(c *echo.Context) error { } func (h *Handler) setAuthCookies(c *echo.Context, accessToken, refreshToken string) { + secure := c.Scheme() == "https" + accessCookie := &http.Cookie{ Name: "access_token", Value: accessToken, Path: "/", HttpOnly: true, - Secure: true, + Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: 900, // 15 minutes } @@ -256,7 +252,7 @@ func (h *Handler) setAuthCookies(c *echo.Context, accessToken, refreshToken stri Value: refreshToken, Path: "/", HttpOnly: true, - Secure: true, + Secure: secure, SameSite: http.SameSiteStrictMode, MaxAge: int(h.service.config.RefreshTokenExpires.Seconds()), } diff --git a/apps/server/internal/routes/router.go b/apps/server/internal/routes/router.go index 4e9c9ad..0187db2 100644 --- a/apps/server/internal/routes/router.go +++ b/apps/server/internal/routes/router.go @@ -6,6 +6,7 @@ import ( "github.com/coderz-space/coderz.space/internal/container" "github.com/coderz-space/coderz.space/internal/modules/analytics" + "github.com/coderz-space/coderz.space/internal/modules/app" "github.com/coderz-space/coderz.space/internal/modules/assignment" "github.com/coderz-space/coderz.space/internal/modules/auth" "github.com/coderz-space/coderz.space/internal/modules/bootcamp" @@ -23,6 +24,10 @@ func RegisterRoutes(e *echo.Group, di *container.Container) { auth.RegisterPublicRoutes(e, di.AuthHandler) auth.RegisterProtectedRoutes(e, di.AuthHandler, di.Config) + // App facade routes + app.RegisterPublicRoutes(e, di.AppHandler) + app.RegisterProtectedRoutes(e, di.AppHandler, di.Config) + // Organization module routes organization.RegisterProtectedRoutes(e, di.OrganizationHandler, di.Config) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md deleted file mode 100644 index 8bd0e39..0000000 --- a/apps/web/AGENTS.md +++ /dev/null @@ -1,5 +0,0 @@ - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. - diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/apps/web/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index e568c26..f3de157 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -11,8 +11,8 @@ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; } .dark { @@ -23,5 +23,5 @@ body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index aa909e7..5904608 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,18 +1,6 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import ThemeToggle from "@/components/ThemeToggle"; -import { StubToastProvider } from "@/components/StubToast"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); - -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); export const metadata: Metadata = { title: "Algo Buddy", @@ -26,24 +14,18 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + - {/* Blocking script: sets dark class before first paint to avoid flash */}