Compare commits
15 Commits
13f14caaa3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 23e5a1ba2b | |||
| f115787ef6 | |||
| 80d0502608 | |||
| 1682fda5e7 | |||
| 3a9687fac4 | |||
| 523ba6e10f | |||
| a700422b4f | |||
| 4e072f2f96 | |||
| 29e8a32864 | |||
| 914521f376 | |||
| 28022c3210 | |||
| 648d67cb29 | |||
| 9e966f1b1a | |||
| 2d03f3a7f4 | |||
| 86b6d6c0b9 |
@@ -0,0 +1,29 @@
|
|||||||
|
# Environment variables for development
|
||||||
|
# Copy this file to .env and update with your values
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
DB_URL=jdbc:postgresql://localhost:5432/mtgsearch
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_MAX_POOL_SIZE=10
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_EXPIRATION=86400
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
APP_NAME=mtg-search
|
||||||
|
APP_VERSION=0.1.0
|
||||||
|
APP_ENVIRONMENT=development
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
SERVER_PORT=8080
|
||||||
|
SERVER_SERVLET_CONTEXT_PATH=/
|
||||||
|
|
||||||
|
# Frontend Configuration
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
VITE_APP_TITLE=MTG Search
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
LOG_DIR=logs
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
/gradlew text eol=lf
|
|
||||||
*.bat text eol=crlf
|
|
||||||
*.jar binary
|
|
||||||
+31
-43
@@ -1,46 +1,34 @@
|
|||||||
node_modules
|
.gradle/
|
||||||
HELP.md
|
|
||||||
.gradle
|
|
||||||
build/
|
build/
|
||||||
!gradle/wrapper/gradle-wrapper.jar
|
|
||||||
!**/src/main/**/build/
|
|
||||||
!**/src/test/**/build/
|
|
||||||
|
|
||||||
### STS ###
|
|
||||||
.apt_generated
|
|
||||||
.classpath
|
|
||||||
.factorypath
|
|
||||||
.project
|
|
||||||
.settings
|
|
||||||
.springBeans
|
|
||||||
.sts4-cache
|
|
||||||
bin/
|
|
||||||
!**/src/main/**/bin/
|
|
||||||
!**/src/test/**/bin/
|
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
|
||||||
.idea
|
|
||||||
*.iws
|
|
||||||
*.iml
|
|
||||||
*.ipr
|
|
||||||
out/
|
out/
|
||||||
!**/src/main/**/out/
|
.idea/
|
||||||
!**/src/test/**/out/
|
*.iml
|
||||||
|
*.class
|
||||||
### NetBeans ###
|
*.jar
|
||||||
/nbproject/private/
|
*.war
|
||||||
/nbbuild/
|
*.nar
|
||||||
/dist/
|
*.zip
|
||||||
/nbdist/
|
*.tar.gz
|
||||||
/.nb-gradle/
|
*.rar
|
||||||
|
pom.xml.tag
|
||||||
### VS Code ###
|
pom.xml.releaseBackup
|
||||||
|
pom.xml.versionsBackup
|
||||||
|
pom.xml.next
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
target/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
*.swp
|
||||||
### Vaadin Ignore
|
*.swo
|
||||||
src/main/frontend/generated/
|
*~
|
||||||
|
.project
|
||||||
### mtg json
|
.classpath
|
||||||
/AllPrintings/
|
.settings/
|
||||||
/AllPrintings.psql.zip
|
*.sublime-workspace
|
||||||
/AllPrintings.psql.zip.sha256
|
*.sublime-project
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
dev.properties
|
||||||
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!gradle/wrapper/gradle-wrapper.properties
|
||||||
|
|||||||
+470
@@ -0,0 +1,470 @@
|
|||||||
|
# Development Guide for MTG Search
|
||||||
|
|
||||||
|
## Local Development Environment Setup
|
||||||
|
|
||||||
|
### Prerequisites Installation
|
||||||
|
|
||||||
|
#### macOS
|
||||||
|
```bash
|
||||||
|
# Install Homebrew if not already installed
|
||||||
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
|
|
||||||
|
# Install Java 21
|
||||||
|
brew install --cask java
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
brew install node@20
|
||||||
|
|
||||||
|
# Install PostgreSQL
|
||||||
|
brew install postgresql
|
||||||
|
|
||||||
|
# Install Docker
|
||||||
|
brew install --cask docker
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Ubuntu/Debian
|
||||||
|
```bash
|
||||||
|
# Install Java 21
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y openjdk-21-jdk
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
|
||||||
|
# Install PostgreSQL
|
||||||
|
sudo apt-get install -y postgresql postgresql-contrib
|
||||||
|
|
||||||
|
# Install Docker
|
||||||
|
curl -fsSL https://get.docker.com | sudo sh
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Windows
|
||||||
|
1. Install Java 21 from [Oracle](https://www.oracle.com/java/technologies/downloads/)
|
||||||
|
2. Install Node.js from [nodejs.org](https://nodejs.org)
|
||||||
|
3. Install PostgreSQL from [postgresql.org](https://www.postgresql.org/download/windows/)
|
||||||
|
4. Install Docker Desktop from [docker.com](https://www.docker.com/products/docker-desktop)
|
||||||
|
|
||||||
|
### Quick Start with Startup Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make script executable
|
||||||
|
chmod +x startup.sh
|
||||||
|
|
||||||
|
# Run the startup script
|
||||||
|
./startup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Setup
|
||||||
|
|
||||||
|
#### Step 1: Start PostgreSQL
|
||||||
|
|
||||||
|
**Using Docker (Recommended):**
|
||||||
|
```bash
|
||||||
|
docker run --name mtgsearch-postgres \
|
||||||
|
-e POSTGRES_DB=mtgsearch \
|
||||||
|
-e POSTGRES_USER=postgres \
|
||||||
|
-e POSTGRES_PASSWORD=postgres \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-d postgres:17-alpine
|
||||||
|
```
|
||||||
|
|
||||||
|
**Using Local PostgreSQL:**
|
||||||
|
```bash
|
||||||
|
# macOS
|
||||||
|
brew services start postgresql
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
sudo systemctl start postgresql
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
# Start from Services or GUI
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 2: Verify Database Connection
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql -U postgres -h localhost -d mtgsearch -c "SELECT 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 3: Build and Run Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Build with Gradle
|
||||||
|
../gradlew build
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
../gradlew bootRun
|
||||||
|
|
||||||
|
# The backend will start on http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Step 4: Set Up and Run Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# The frontend will start on http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Making Changes
|
||||||
|
|
||||||
|
#### Backend Changes
|
||||||
|
1. Edit Java files in `backend/src/main/java`
|
||||||
|
2. Spring Boot DevTools will auto-reload
|
||||||
|
3. Check logs: `tail -f logs/mtg-search.log`
|
||||||
|
|
||||||
|
#### Frontend Changes
|
||||||
|
1. Edit Vue files in `frontend/src`
|
||||||
|
2. Vite will hot-reload automatically
|
||||||
|
3. Check browser console for errors
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
#### Backend Tests
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Run all tests
|
||||||
|
../gradlew test
|
||||||
|
|
||||||
|
# Run specific test class
|
||||||
|
../gradlew test --tests AuthServiceTest
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
../gradlew test jacocoTestReport
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend Tests
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
npm run test -- --coverage
|
||||||
|
|
||||||
|
# Watch mode
|
||||||
|
npm run test -- --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
#### Backend
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Run checks (spotless, checkstyle, etc.)
|
||||||
|
../gradlew check
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
../gradlew spotlessApply
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Frontend
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Run linter
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# Fix linting issues
|
||||||
|
npm run lint --fix
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Management
|
||||||
|
|
||||||
|
### Running Flyway Migrations
|
||||||
|
|
||||||
|
Migrations run automatically when the application starts. To manage migrations manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Show migration status
|
||||||
|
../gradlew flywayInfo
|
||||||
|
|
||||||
|
# Validate migrations
|
||||||
|
../gradlew flywayValidate
|
||||||
|
|
||||||
|
# Repair (use with caution)
|
||||||
|
../gradlew flywayRepair
|
||||||
|
|
||||||
|
# Clean schema (WARNING: deletes all data)
|
||||||
|
../gradlew flywayClean
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating New Migration
|
||||||
|
|
||||||
|
1. Create file: `backend/src/main/resources/db/migration/V{NUMBER}__Description.sql`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```sql
|
||||||
|
-- V2__Add_search_history_table.sql
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||||
|
search_query VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_search_history_user_id ON search_history(user_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints Testing
|
||||||
|
|
||||||
|
### Using curl
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Register a new user
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/register \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "SecurePassword123!"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Login
|
||||||
|
curl -X POST http://localhost:8080/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "SecurePassword123!"
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Use token in requests
|
||||||
|
TOKEN=<your-jwt-token>
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" \
|
||||||
|
http://localhost:8080/api/v1/protected-endpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Swagger UI
|
||||||
|
|
||||||
|
Navigate to: http://localhost:8080/swagger-ui.html
|
||||||
|
|
||||||
|
## Debugging
|
||||||
|
|
||||||
|
### Backend Debugging
|
||||||
|
|
||||||
|
#### In IDE (IntelliJ IDEA)
|
||||||
|
1. Set breakpoints in your code
|
||||||
|
2. Run → Debug 'MtgSearchApplication'
|
||||||
|
3. Step through code
|
||||||
|
|
||||||
|
#### Remote Debugging
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
../gradlew bootRun --args='--debug'
|
||||||
|
|
||||||
|
# Then connect remote debugger on port 5005
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Debugging
|
||||||
|
|
||||||
|
1. Open browser DevTools (F12 or Cmd+Option+I)
|
||||||
|
2. Check Console, Network, and Application tabs
|
||||||
|
3. Use Vue DevTools browser extension
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend logs
|
||||||
|
tail -f logs/mtg-search.log
|
||||||
|
|
||||||
|
# Backend errors
|
||||||
|
tail -f logs/mtg-search-error.log
|
||||||
|
|
||||||
|
# Frontend browser console
|
||||||
|
# Open DevTools → Console tab
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create `.env` file in project root:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database
|
||||||
|
DB_URL=jdbc:postgresql://localhost:5432/mtgsearch
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=development-secret-key
|
||||||
|
JWT_EXPIRATION=86400
|
||||||
|
|
||||||
|
# Application
|
||||||
|
SPRING_PROFILE=dev
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## IDE Configuration
|
||||||
|
|
||||||
|
### IntelliJ IDEA
|
||||||
|
1. Open project root folder
|
||||||
|
2. File → Project Structure → Modules
|
||||||
|
3. Select backend module
|
||||||
|
4. Set Java 21 as SDK
|
||||||
|
5. Install Spring Boot plugin
|
||||||
|
6. Install Lombok plugin
|
||||||
|
|
||||||
|
### VS Code
|
||||||
|
1. Install extensions:
|
||||||
|
- Extension Pack for Java
|
||||||
|
- Spring Boot Extension Pack
|
||||||
|
- Vue 3 Extension
|
||||||
|
- TypeScript Vue Plugin
|
||||||
|
2. Configure Java home in settings
|
||||||
|
3. Install Node modules
|
||||||
|
|
||||||
|
## Useful Gradle Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build tasks
|
||||||
|
./gradlew build # Full build
|
||||||
|
./gradlew clean build # Clean and build
|
||||||
|
./gradlew build -x test # Build without tests
|
||||||
|
|
||||||
|
# Test tasks
|
||||||
|
./gradlew test # Run tests
|
||||||
|
./gradlew test --watch # Watch mode
|
||||||
|
|
||||||
|
# Development tasks
|
||||||
|
./gradlew bootRun # Run application
|
||||||
|
./gradlew bootRun --debug # Debug mode
|
||||||
|
|
||||||
|
# Code generation
|
||||||
|
./gradlew openApiGenerate # Generate from OpenAPI spec
|
||||||
|
./gradlew generateJooqCode # Generate jOOQ entities
|
||||||
|
|
||||||
|
# Database tasks
|
||||||
|
./gradlew flywayInfo # Show migration status
|
||||||
|
./gradlew flywayMigrate # Run migrations
|
||||||
|
```
|
||||||
|
|
||||||
|
## Useful npm Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev # Start dev server
|
||||||
|
npm run build # Build for production
|
||||||
|
npm run preview # Preview production build
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
npm run test # Run tests
|
||||||
|
npm run test:ui # Run tests with UI
|
||||||
|
npm run test -- --watch # Watch mode
|
||||||
|
|
||||||
|
# Quality
|
||||||
|
npm run lint # Run linter
|
||||||
|
npm run type-check # Check TypeScript types
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting Common Issues
|
||||||
|
|
||||||
|
### "Address already in use" port 8080
|
||||||
|
```bash
|
||||||
|
# Find process using port 8080
|
||||||
|
lsof -i :8080
|
||||||
|
|
||||||
|
# Kill the process
|
||||||
|
kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### "Address already in use" port 5173
|
||||||
|
```bash
|
||||||
|
# Find process using port 5173
|
||||||
|
lsof -i :5173
|
||||||
|
|
||||||
|
# Kill the process
|
||||||
|
kill -9 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### PostgreSQL connection refused
|
||||||
|
```bash
|
||||||
|
# Check if PostgreSQL is running
|
||||||
|
psql --version
|
||||||
|
|
||||||
|
# Try to connect
|
||||||
|
psql -U postgres -h localhost
|
||||||
|
|
||||||
|
# If using Docker, check container
|
||||||
|
docker ps | grep postgres
|
||||||
|
docker logs mtgsearch-postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gradle build fails
|
||||||
|
```bash
|
||||||
|
# Clear gradle cache
|
||||||
|
rm -rf ~/.gradle/caches
|
||||||
|
|
||||||
|
# Clean and rebuild
|
||||||
|
./gradlew clean build --refresh-dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend build fails
|
||||||
|
```bash
|
||||||
|
# Clear node_modules
|
||||||
|
rm -rf frontend/node_modules package-lock.json
|
||||||
|
|
||||||
|
# Reinstall dependencies
|
||||||
|
cd frontend && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flyway migration fails
|
||||||
|
```bash
|
||||||
|
# Check migration status
|
||||||
|
cd backend && ../gradlew flywayInfo
|
||||||
|
|
||||||
|
# Validate migrations
|
||||||
|
../gradlew flywayValidate
|
||||||
|
|
||||||
|
# Start fresh (DELETES ALL DATA)
|
||||||
|
../gradlew flywayClean
|
||||||
|
../gradlew flywayMigrate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Profiling
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
# Generate JFR recording
|
||||||
|
cd backend
|
||||||
|
../gradlew bootRun --jvmArgs="-XX:StartFlightRecording=filename=recording.jfr"
|
||||||
|
|
||||||
|
# Analyze with Java Flight Recorder
|
||||||
|
jmc recording.jfr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
```bash
|
||||||
|
# Build with source maps
|
||||||
|
cd frontend
|
||||||
|
npm run build -- --sourcemap
|
||||||
|
|
||||||
|
# Use Chrome DevTools Performance tab
|
||||||
|
```
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
- [Spring Boot Documentation](https://spring.io/projects/spring-boot)
|
||||||
|
- [Vue 3 Documentation](https://vuejs.org)
|
||||||
|
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
|
||||||
|
- [Gradle Documentation](https://gradle.org/guides/)
|
||||||
|
- [Docker Documentation](https://docs.docker.com/)
|
||||||
|
- [GitLab CI/CD Documentation](https://docs.gitlab.com/ee/ci/)
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
# Multi-stage build for MTG Search
|
||||||
|
# Stage 1: Build backend and frontend
|
||||||
|
FROM eclipse-temurin:21.0.2-jdk as builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
RUN apt-get update && apt-get install -y curl && \
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||||
|
apt-get install -y nodejs && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy gradle files
|
||||||
|
COPY gradle gradle
|
||||||
|
COPY gradlew .
|
||||||
|
COPY build.gradle.kts .
|
||||||
|
COPY settings.gradle.kts .
|
||||||
|
COPY gradle.properties .
|
||||||
|
|
||||||
|
# Copy backend source
|
||||||
|
COPY backend backend
|
||||||
|
|
||||||
|
# Copy frontend source
|
||||||
|
COPY frontend frontend
|
||||||
|
|
||||||
|
# Build backend
|
||||||
|
WORKDIR /build/backend
|
||||||
|
RUN ../gradlew build -x test
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
WORKDIR /build/frontend
|
||||||
|
RUN npm ci && npm run build
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM eclipse-temurin:21.0.2-jre
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for health checks
|
||||||
|
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy built backend jar
|
||||||
|
COPY --from=builder /build/backend/build/libs/*.jar app.jar
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/api/v1/auth/health || exit 1
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
Vendored
+189
@@ -0,0 +1,189 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
timeout(time: 1, unit: 'HOURS')
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '10'))
|
||||||
|
}
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
booleanParam(name: 'DEPLOY_TO_STAGING', defaultValue: false, description: 'Deploy to staging')
|
||||||
|
booleanParam(name: 'DEPLOY_TO_PRODUCTION', defaultValue: false, description: 'Deploy to production')
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
// Docker registry configuration
|
||||||
|
REGISTRY = credentials('docker-registry-url')
|
||||||
|
REGISTRY_USER = credentials('docker-registry-user')
|
||||||
|
REGISTRY_PASSWORD = credentials('docker-registry-password')
|
||||||
|
DOCKER_IMAGE = "${REGISTRY}/${GIT_COMMIT.take(8)}"
|
||||||
|
DOCKER_IMAGE_LATEST = "${REGISTRY}:latest"
|
||||||
|
|
||||||
|
// Database configuration for builds
|
||||||
|
DB_URL = "jdbc:postgresql://postgres:5432/mtgsearch_build"
|
||||||
|
DB_USER = "postgres"
|
||||||
|
DB_PASSWORD = "postgres"
|
||||||
|
POSTGRES_DB = "mtgsearch_build"
|
||||||
|
|
||||||
|
// SonarQube configuration
|
||||||
|
SONAR_USER_HOME = "${WORKSPACE}/.sonar"
|
||||||
|
GIT_DEPTH = "0"
|
||||||
|
|
||||||
|
// Build cache
|
||||||
|
GRADLE_USER_HOME = "${WORKSPACE}/.gradle"
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Build Java & Frontend') {
|
||||||
|
agent any
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
echo "Building Java and Frontend..."
|
||||||
|
}
|
||||||
|
sh '''
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew clean build -x test --build-cache
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
success {
|
||||||
|
archiveArtifacts artifacts: 'backend/build/libs/**/*.jar,frontend/dist/**/*', fingerprint: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Run Unit Tests') {
|
||||||
|
agent any
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
echo "Running unit tests..."
|
||||||
|
}
|
||||||
|
sh '''
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew test --build-cache
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
junit 'backend/build/test-results/test/**/*.xml'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Run Integration Tests') {
|
||||||
|
agent {
|
||||||
|
docker {
|
||||||
|
image 'eclipse-temurin:21-jdk'
|
||||||
|
args '--network=host'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
anyOf {
|
||||||
|
branch 'main'
|
||||||
|
branch 'develop'
|
||||||
|
any echo "Running integration tests..."
|
||||||
|
}
|
||||||
|
sh '''
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew integrationTest --build-cache
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
junit 'backend/build/test-results/integrationTest/**/*.xml'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Code Quality Checks') {
|
||||||
|
agent {
|
||||||
|
docker {
|
||||||
|
image 'eclipse-temurin:21-jdk'
|
||||||
|
args '--network=host'
|
||||||
|
}
|
||||||
|
}any script {
|
||||||
|
echo "Running code quality checks..."
|
||||||
|
}
|
||||||
|
sh '''
|
||||||
|
chmod +x ./gradlew
|
||||||
|
./gradlew check -x test
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
failure {
|
||||||
|
echo "Quality checks failed - continuing pipeline with warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('SonarQube Analysis') {
|
||||||
|
withSonarQubeEnv() {
|
||||||
|
sh "./gradlew sonar -Dsonar.projectKey=MTGSearch -Dsonar.projectName='MTGSearch' -Dsonar.host.url=https://sonarqube.moustos.net -Dsonar.token=sqp_452a7bbe843fafe78b1a45efaf7ffb86ab6ba881"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy to Staging') {
|
||||||
|
when {
|
||||||
|
allOf {
|
||||||
|
branch 'develop'
|
||||||
|
expression { params.DEPLOY_TO_STAGING == true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
echo "Deploying to staging environment..."
|
||||||
|
input(id: 'DeployToStaging', message: 'Deploy to staging?', ok: 'Deploy')
|
||||||
|
}
|
||||||
|
sh '''
|
||||||
|
curl -X POST "${STAGING_DEPLOY_WEBHOOK}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"image":"${DOCKER_IMAGE}"}'
|
||||||
|
echo "Deployment to staging triggered"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy to Production') {
|
||||||
|
when {
|
||||||
|
anyOf {
|
||||||
|
allOf {
|
||||||
|
branch 'main'
|
||||||
|
expression { params.DEPLOY_TO_PRODUCTION == true }
|
||||||
|
}
|
||||||
|
buildingTag()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
echo "Deploying to production environment..."
|
||||||
|
input(id: 'DeployToProd', message: 'Deploy to production?', ok: 'Deploy')
|
||||||
|
}
|
||||||
|
sh '''
|
||||||
|
curl -X POST "${PROD_DEPLOY_WEBHOOK}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"image":"${DOCKER_IMAGE}"}'
|
||||||
|
echo "Deployment to production triggered"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
cleanWs()
|
||||||
|
}
|
||||||
|
failure {
|
||||||
|
script {
|
||||||
|
echo "Pipeline failed! Check logs for details."
|
||||||
|
// You can add notification logic here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
success {
|
||||||
|
script {
|
||||||
|
echo "Pipeline completed successfully!"
|
||||||
|
// You can add notification logic here
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# MTG Search - Makefile for common development tasks
|
||||||
|
|
||||||
|
.PHONY: help build test run clean docker-build docker-run docker-stop frontend-install frontend-build backend-build
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "MTG Search - Available Commands"
|
||||||
|
@echo "================================"
|
||||||
|
@echo "make build - Build both backend and frontend"
|
||||||
|
@echo "make backend-build - Build backend only"
|
||||||
|
@echo "make frontend-build - Build frontend only"
|
||||||
|
@echo "make test - Run all tests"
|
||||||
|
@echo "make backend-test - Run backend tests"
|
||||||
|
@echo "make frontend-test - Run frontend tests"
|
||||||
|
@echo "make run - Run backend server (requires frontend built)"
|
||||||
|
@echo "make dev - Run frontend dev server and backend"
|
||||||
|
@echo "make clean - Clean all build directories"
|
||||||
|
@echo "make docker-build - Build Docker image"
|
||||||
|
@echo "make docker-run - Start services with docker-compose"
|
||||||
|
@echo "make docker-stop - Stop all docker-compose services"
|
||||||
|
@echo "make docker-clean - Remove all containers and volumes"
|
||||||
|
@echo "make lint - Run code quality checks"
|
||||||
|
@echo "make format - Format code"
|
||||||
|
@echo "make db-init - Initialize database"
|
||||||
|
|
||||||
|
build: backend-build frontend-build
|
||||||
|
@echo "✓ Build complete"
|
||||||
|
|
||||||
|
backend-build:
|
||||||
|
@echo "Building backend..."
|
||||||
|
cd backend && ../gradlew clean build -x test
|
||||||
|
@echo "✓ Backend build complete"
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
@echo "Building frontend..."
|
||||||
|
cd frontend && npm install && npm run build
|
||||||
|
@echo "✓ Frontend build complete"
|
||||||
|
|
||||||
|
frontend-install:
|
||||||
|
@echo "Installing frontend dependencies..."
|
||||||
|
cd frontend && npm install
|
||||||
|
@echo "✓ Frontend dependencies installed"
|
||||||
|
|
||||||
|
test: backend-test
|
||||||
|
@echo "✓ All tests passed"
|
||||||
|
|
||||||
|
backend-test:
|
||||||
|
@echo "Running backend tests..."
|
||||||
|
cd backend && ../gradlew test
|
||||||
|
|
||||||
|
frontend-test:
|
||||||
|
@echo "Running frontend tests..."
|
||||||
|
cd frontend && npm run test
|
||||||
|
|
||||||
|
run:
|
||||||
|
@echo "Starting backend server..."
|
||||||
|
cd backend && ../gradlew bootRun
|
||||||
|
|
||||||
|
dev: frontend-install
|
||||||
|
@echo "Starting development environment..."
|
||||||
|
@echo "Frontend: http://localhost:5173"
|
||||||
|
@echo "Backend: http://localhost:8080"
|
||||||
|
@echo ""
|
||||||
|
@trap 'kill %1 %2' SIGINT; \
|
||||||
|
(cd frontend && npm run dev) & \
|
||||||
|
(cd backend && ../gradlew bootRun) & \
|
||||||
|
wait
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@echo "Cleaning build directories..."
|
||||||
|
cd backend && ../gradlew clean
|
||||||
|
cd frontend && rm -rf node_modules dist
|
||||||
|
rm -rf logs
|
||||||
|
@echo "✓ Clean complete"
|
||||||
|
|
||||||
|
docker-build:
|
||||||
|
@echo "Building Docker image..."
|
||||||
|
docker build -t mtgsearch:latest .
|
||||||
|
@echo "✓ Docker build complete"
|
||||||
|
|
||||||
|
docker-run:
|
||||||
|
@echo "Starting Docker services..."
|
||||||
|
docker-compose up --build
|
||||||
|
@echo "✓ Services started"
|
||||||
|
|
||||||
|
docker-stop:
|
||||||
|
@echo "Stopping Docker services..."
|
||||||
|
docker-compose down
|
||||||
|
@echo "✓ Services stopped"
|
||||||
|
|
||||||
|
docker-clean:
|
||||||
|
@echo "Cleaning Docker resources..."
|
||||||
|
docker-compose down -v
|
||||||
|
docker image rm mtgsearch:latest
|
||||||
|
@echo "✓ Docker cleanup complete"
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@echo "Running linters..."
|
||||||
|
cd backend && ../gradlew check -x test
|
||||||
|
cd frontend && npm run lint
|
||||||
|
@echo "✓ Linting complete"
|
||||||
|
|
||||||
|
format:
|
||||||
|
@echo "Formatting code..."
|
||||||
|
cd frontend && npm run lint
|
||||||
|
@echo "✓ Formatting complete"
|
||||||
|
|
||||||
|
db-init:
|
||||||
|
@echo "Initializing database..."
|
||||||
|
docker run --name mtgsearch-postgres \
|
||||||
|
-e POSTGRES_DB=mtgsearch \
|
||||||
|
-e POSTGRES_USER=postgres \
|
||||||
|
-e POSTGRES_PASSWORD=postgres \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-d postgres:17-alpine
|
||||||
|
@echo "✓ Database initialized on localhost:5432"
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
# MTG Search - Magic The Gathering Card Search Platform
|
||||||
|
|
||||||
|
A full-stack web application for searching and exploring Magic The Gathering cards with user authentication, built with Spring Boot, Vue.js, PostgreSQL, and deployed with Docker.
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
├── Backend (Java 21, Spring Boot 3.2.5)
|
||||||
|
│ ├── REST API with OpenAPI/Swagger documentation
|
||||||
|
│ ├── JWT-based authentication
|
||||||
|
│ ├── PostgreSQL database with jOOQ ORM
|
||||||
|
│ ├── Flyway database migrations
|
||||||
|
│ ├── Comprehensive logging with Logback
|
||||||
|
│ └── JUnit 5 and integration tests
|
||||||
|
│
|
||||||
|
├── Frontend (Vue 3, TypeScript, Vite)
|
||||||
|
│ ├── Single Page Application (SPA)
|
||||||
|
│ ├── Responsive UI with CSS Grid/Flexbox
|
||||||
|
│ ├── Pinia state management
|
||||||
|
│ ├── Vue Router with auth guards
|
||||||
|
│ └── Axios HTTP client with JWT interceptors
|
||||||
|
│
|
||||||
|
├── Database (PostgreSQL 16)
|
||||||
|
│ ├── Schema managed by Flyway migrations
|
||||||
|
│ ├── jOOQ-generated DTOs and repositories
|
||||||
|
│ └── Indexed queries for performance
|
||||||
|
│
|
||||||
|
└── Deployment
|
||||||
|
├── Docker multi-stage build
|
||||||
|
├── Docker Compose for local dev
|
||||||
|
└── GitLab CI/CD pipeline
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Java 21** or later
|
||||||
|
- **Node.js 20+** and npm 10+
|
||||||
|
- **PostgreSQL 16+**
|
||||||
|
- **Docker & Docker Compose** (for containerized setup)
|
||||||
|
- **Git**
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Using Docker Compose (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone the repository
|
||||||
|
git clone <repository-url>
|
||||||
|
cd mtg-search
|
||||||
|
|
||||||
|
# Start all services (PostgreSQL, Backend, built Frontend)
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
# Backend will be available at: http://localhost:8080
|
||||||
|
# Frontend will be served by the backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Development Setup
|
||||||
|
|
||||||
|
#### 1. Database Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start PostgreSQL (using Docker)
|
||||||
|
docker run --name mtgsearch-postgres \
|
||||||
|
-e POSTGRES_DB=mtgsearch \
|
||||||
|
-e POSTGRES_USER=postgres \
|
||||||
|
-e POSTGRES_PASSWORD=postgres \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-d postgres:17-alpine
|
||||||
|
|
||||||
|
# Or use your local PostgreSQL installation
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Backend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# Build the project
|
||||||
|
../gradlew build
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
../gradlew bootRun
|
||||||
|
|
||||||
|
# Backend starts on http://localhost:8080
|
||||||
|
# Swagger UI available at: http://localhost:8080/swagger-ui.html
|
||||||
|
# API Docs available at: http://localhost:8080/v3/api-docs
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Frontend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Frontend starts on http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- **POST** `/api/v1/auth/register` - Register a new user
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "john_doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"password": "SecurePassword123!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **POST** `/api/v1/auth/login` - Login and get JWT token
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "john_doe",
|
||||||
|
"password": "SecurePassword123!"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Returns:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"token": "eyJhbGc...",
|
||||||
|
"id": 1,
|
||||||
|
"username": "john_doe",
|
||||||
|
"email": "john@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **GET** `/api/v1/auth/health` - Health check (no auth required)
|
||||||
|
|
||||||
|
### Protected Endpoints
|
||||||
|
|
||||||
|
All protected endpoints require JWT token in Authorization header:
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Build Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew clean build
|
||||||
|
|
||||||
|
# Build outputs to: backend/build/libs/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Build outputs to: backend/src/main/resources/static/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Full Docker Image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t mtgsearch:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Backend Unit Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend Integration Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew integrationTest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run test
|
||||||
|
npm run test:ui # Opens UI dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### All Tests with Coverage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test jacocoTestReport
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Database
|
||||||
|
DB_URL=jdbc:postgresql://localhost:5432/mtgsearch
|
||||||
|
DB_USER=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
JWT_EXPIRATION=86400
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Properties
|
||||||
|
|
||||||
|
Backend configuration in `backend/src/main/resources/application.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: jdbc:postgresql://localhost:5432/mtgsearch
|
||||||
|
username: postgres
|
||||||
|
password: postgres
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
|
||||||
|
app:
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:your-secret-key}
|
||||||
|
expiration: ${JWT_EXPIRATION:86400}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
Migrations are managed by Flyway and located in `backend/src/main/resources/db/migration/`
|
||||||
|
|
||||||
|
### Adding a New Migration
|
||||||
|
|
||||||
|
1. Create a new file: `V2__Add_new_table.sql`
|
||||||
|
2. Write SQL migration
|
||||||
|
3. Run application - Flyway will auto-apply migrations
|
||||||
|
|
||||||
|
Example migration:
|
||||||
|
```sql
|
||||||
|
-- V2__Add_cards_table.sql
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Logs are configured in `backend/src/main/resources/logback-spring.xml`
|
||||||
|
|
||||||
|
- **Log Location**: `logs/mtg-search.log`
|
||||||
|
- **Error Logs**: `logs/mtg-search-error.log`
|
||||||
|
- **Max File Size**: 100MB
|
||||||
|
- **Retention**: 30 days
|
||||||
|
- **Compression**: Automatic gzip compression
|
||||||
|
|
||||||
|
View logs:
|
||||||
|
```bash
|
||||||
|
tail -f logs/mtg-search.log
|
||||||
|
tail -f logs/mtg-search-error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Code Generation
|
||||||
|
|
||||||
|
The project uses OpenAPI Generator and jOOQ for code generation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate server stubs from OpenAPI spec
|
||||||
|
./gradlew openApiGenerate
|
||||||
|
|
||||||
|
# Generate jOOQ entities from database schema
|
||||||
|
./gradlew generateJooqCode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Format code
|
||||||
|
./gradlew spotlessApply
|
||||||
|
|
||||||
|
# Run static analysis
|
||||||
|
./gradlew check
|
||||||
|
|
||||||
|
# Frontend linting
|
||||||
|
cd frontend
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Staging Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
git merge feature-branch
|
||||||
|
git push origin develop
|
||||||
|
|
||||||
|
# GitLab CI will automatically build and deploy to staging
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag v0.2.0
|
||||||
|
git push origin v0.2.0
|
||||||
|
|
||||||
|
# GitLab CI will build, test, and deploy to production
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build Docker image
|
||||||
|
docker build -t mtgsearch:v0.1.0 .
|
||||||
|
docker tag mtgsearch:v0.1.0 your-registry/mtgsearch:v0.1.0
|
||||||
|
|
||||||
|
# Push to registry
|
||||||
|
docker push your-registry/mtgsearch:v0.1.0
|
||||||
|
|
||||||
|
# Pull and run on server
|
||||||
|
docker pull your-registry/mtgsearch:v0.1.0
|
||||||
|
docker run -d \
|
||||||
|
-e SPRING_DATASOURCE_URL=jdbc:postgresql://db-host:5432/mtgsearch \
|
||||||
|
-e SPRING_DATASOURCE_USERNAME=postgres \
|
||||||
|
-e SPRING_DATASOURCE_PASSWORD=secure-password \
|
||||||
|
-e JWT_SECRET=your-production-secret \
|
||||||
|
-p 8080:8080 \
|
||||||
|
--name mtgsearch \
|
||||||
|
your-registry/mtgsearch:v0.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Pipeline
|
||||||
|
|
||||||
|
The project includes a comprehensive GitLab CI/CD pipeline (`.gitlab-ci.yml`):
|
||||||
|
|
||||||
|
1. **Build Stage**
|
||||||
|
- Compiles Java backend
|
||||||
|
- Builds Vue.js frontend
|
||||||
|
- Caches dependencies
|
||||||
|
|
||||||
|
2. **Test Stage**
|
||||||
|
- Unit tests
|
||||||
|
- Integration tests with PostgreSQL
|
||||||
|
- Code quality checks
|
||||||
|
|
||||||
|
3. **Docker Stage**
|
||||||
|
- Builds multi-stage Docker image
|
||||||
|
- Pushes to registry
|
||||||
|
|
||||||
|
4. **Deploy Stage**
|
||||||
|
- Deploys to staging with manual trigger
|
||||||
|
- Deploys to production on version tags
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
mtg-search/
|
||||||
|
├── backend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main/
|
||||||
|
│ │ │ ├── java/net/moustos/mtgsearch/
|
||||||
|
│ │ │ │ ├── MtgSearchApplication.java
|
||||||
|
│ │ │ │ ├── config/
|
||||||
|
│ │ │ │ ├── controller/
|
||||||
|
│ │ │ │ ├── service/
|
||||||
|
│ │ │ │ ├── repository/
|
||||||
|
│ │ │ │ ├── model/
|
||||||
|
│ │ │ │ └── security/
|
||||||
|
│ │ │ └── resources/
|
||||||
|
│ │ │ ├── application.yml
|
||||||
|
│ │ │ ├── logback-spring.xml
|
||||||
|
│ │ │ └── db/migration/
|
||||||
|
│ │ └── test/
|
||||||
|
│ │ └── java/
|
||||||
|
│ └── build.gradle.kts
|
||||||
|
│
|
||||||
|
├── frontend/
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── pages/
|
||||||
|
│ │ ├── components/
|
||||||
|
│ │ ├── services/
|
||||||
|
│ │ ├── stores/
|
||||||
|
│ │ ├── router/
|
||||||
|
│ │ ├── types/
|
||||||
|
│ │ ├── main.ts
|
||||||
|
│ │ └── App.vue
|
||||||
|
│ ├── public/
|
||||||
|
│ ├── index.html
|
||||||
|
│ ├── package.json
|
||||||
|
│ ├── vite.config.ts
|
||||||
|
│ └── tsconfig.json
|
||||||
|
│
|
||||||
|
├── gradle/
|
||||||
|
├── build.gradle.kts
|
||||||
|
├── settings.gradle.kts
|
||||||
|
├── gradle.properties
|
||||||
|
├── gradlew
|
||||||
|
├── gradlew.bat
|
||||||
|
│
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
├── .gitlab-ci.yml
|
||||||
|
├── .gitignore
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backend won't start
|
||||||
|
```bash
|
||||||
|
# Check if port 8080 is already in use
|
||||||
|
lsof -i :8080
|
||||||
|
|
||||||
|
# Check PostgreSQL is running
|
||||||
|
psql -U postgres -h localhost -d mtgsearch -c "SELECT 1;"
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
tail -f logs/mtg-search.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend connection issues
|
||||||
|
- Check backend is running on 8080
|
||||||
|
- Check browser console for errors
|
||||||
|
- Verify CORS settings in SecurityConfig.java
|
||||||
|
|
||||||
|
### Database migration failures
|
||||||
|
```bash
|
||||||
|
# Check migration status
|
||||||
|
./gradlew flywayInfo
|
||||||
|
|
||||||
|
# Repair migrations (use with caution)
|
||||||
|
./gradlew flywayRepair
|
||||||
|
|
||||||
|
# Clean database (WARNING: deletes all data)
|
||||||
|
./gradlew flywayClean
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker build fails
|
||||||
|
```bash
|
||||||
|
# Clear Docker cache
|
||||||
|
docker system prune -a
|
||||||
|
|
||||||
|
# Rebuild with no cache
|
||||||
|
docker build --no-cache -t mtgsearch:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Connection pooling: HikariCP configured with 10 max connections
|
||||||
|
- Database indexing on frequently queried columns
|
||||||
|
- Flyway async migration setup
|
||||||
|
- Request caching headers
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Code splitting with Vite
|
||||||
|
- Lazy route loading
|
||||||
|
- Production build minification
|
||||||
|
- Gzip compression
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- Query result caching
|
||||||
|
- Indexed columns for search operations
|
||||||
|
- Batch operations optimization
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Password Hashing**: BCrypt with strength 12
|
||||||
|
- **JWT Tokens**: Auth0 library with HMAC256
|
||||||
|
- **CORS**: Configured for development (update for production)
|
||||||
|
- **HTTPS**: Required for production deployment
|
||||||
|
- **SQL Injection**: Protected via parameterized queries with jOOQ
|
||||||
|
- **CSRF**: Disabled for API (JWT-based auth)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Create a feature branch: `git checkout -b feature/amazing-feature`
|
||||||
|
2. Commit changes: `git commit -m 'Add amazing feature'`
|
||||||
|
3. Push to branch: `git push origin feature/amazing-feature`
|
||||||
|
4. Open a Pull Request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - see LICENSE file for details
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions:
|
||||||
|
- Open an issue on GitLab
|
||||||
|
- Check existing documentation
|
||||||
|
- Review API Swagger docs at `/swagger-ui.html`
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- [ ] Card search functionality with filters
|
||||||
|
- [ ] Deck building feature
|
||||||
|
- [ ] Social features (friends, sharing)
|
||||||
|
- [ ] Mobile app
|
||||||
|
- [ ] Advanced analytics
|
||||||
|
- [ ] Magic Online API integration
|
||||||
|
- [ ] Performance tuning for large datasets
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import org.gradle.api.tasks.testing.logging.TestLogEvent
|
||||||
|
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||||
|
import java.sql.DriverManager
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Version strings for libraries
|
||||||
|
val postgresqlVersion: String = "42.7.10"
|
||||||
|
val flywayVersion: String = "12.4.0"
|
||||||
|
val jooqVersion: String = "3.21.0"
|
||||||
|
val logbackVersion: String = "1.5.0"
|
||||||
|
val springSecurityVersion: String = "6.3.1"
|
||||||
|
val springdocOpenApiVersion: String = "2.5.0"
|
||||||
|
val jacksonVersion: String = "2.18.0"
|
||||||
|
val commonsLang3Version: String = "3.14.0"
|
||||||
|
val guavaVersion: String = "33.1.0-jre"
|
||||||
|
val lombokVersion: String = "1.18.30"
|
||||||
|
val junitVersion: String = "5.10.2"
|
||||||
|
val mockitoVersion: String = "5.11.0"
|
||||||
|
val testcontainersVersion: String = "1.20.0"
|
||||||
|
val h2Version: String = "2.3.232"
|
||||||
|
val restAssuredVersion: String = "5.4.0"
|
||||||
|
|
||||||
|
|
||||||
|
buildscript {
|
||||||
|
dependencies {
|
||||||
|
classpath("org.postgresql:postgresql:42.7.10")
|
||||||
|
classpath("org.flywaydb:flyway-database-postgresql:12.4.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plugins {
|
||||||
|
id("org.springframework.boot") version "4.0.+"
|
||||||
|
id("io.spring.dependency-management") version "1.1.6"
|
||||||
|
id("org.openapi.generator") version "7.6.0"
|
||||||
|
id("nu.studer.jooq") version "9.0"
|
||||||
|
id("org.flywaydb.flyway") version "12.4.0"
|
||||||
|
id("application")
|
||||||
|
id("java")
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "net.moustos"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-web")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-security")
|
||||||
|
implementation("org.springframework.boot:spring-boot-starter-validation")
|
||||||
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
|
|
||||||
|
implementation("org.postgresql:postgresql:$postgresqlVersion")
|
||||||
|
implementation("org.flywaydb:flyway-core:$flywayVersion")
|
||||||
|
implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion")
|
||||||
|
|
||||||
|
implementation("org.jooq:jooq:$jooqVersion")
|
||||||
|
implementation("org.jooq:jooq-meta:$jooqVersion")
|
||||||
|
implementation("org.jooq:jooq-codegen:$jooqVersion")
|
||||||
|
jooqGenerator("org.postgresql:postgresql:$postgresqlVersion")
|
||||||
|
jooqGenerator("org.jooq:jooq:$jooqVersion")
|
||||||
|
jooqGenerator("org.jooq:jooq-meta:$jooqVersion")
|
||||||
|
jooqGenerator("org.jooq:jooq-codegen:$jooqVersion")
|
||||||
|
|
||||||
|
implementation("com.auth0:java-jwt:4.4.0")
|
||||||
|
implementation("org.springframework.security:spring-security-crypto:$springSecurityVersion")
|
||||||
|
|
||||||
|
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocOpenApiVersion")
|
||||||
|
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion")
|
||||||
|
|
||||||
|
implementation("ch.qos.logback:logback-core:$logbackVersion")
|
||||||
|
implementation("ch.qos.logback:logback-classic:$logbackVersion")
|
||||||
|
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
|
||||||
|
|
||||||
|
compileOnly("org.projectlombok:lombok:$lombokVersion")
|
||||||
|
annotationProcessor("org.projectlombok:lombok:$lombokVersion")
|
||||||
|
|
||||||
|
implementation("org.apache.commons:commons-lang3:$commonsLang3Version")
|
||||||
|
implementation("com.google.guava:guava:$guavaVersion")
|
||||||
|
|
||||||
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
|
testImplementation("org.springframework.security:spring-security-test")
|
||||||
|
testImplementation("org.testcontainers:testcontainers:$testcontainersVersion")
|
||||||
|
testImplementation("org.testcontainers:junit-jupiter:$testcontainersVersion")
|
||||||
|
testImplementation("org.testcontainers:postgresql:$testcontainersVersion")
|
||||||
|
testImplementation("com.h2database:h2:$h2Version")
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter:$junitVersion")
|
||||||
|
testImplementation("org.mockito:mockito-core:$mockitoVersion")
|
||||||
|
testImplementation("org.mockito:mockito-junit-jupiter:$mockitoVersion")
|
||||||
|
testImplementation("io.rest-assured:rest-assured:$restAssuredVersion")
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion.set(JavaLanguageVersion.of(21))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
application {
|
||||||
|
mainClass.set("net.moustos.mtgsearch.MtgSearchApplication")
|
||||||
|
}
|
||||||
|
|
||||||
|
springBoot {
|
||||||
|
buildInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
openApiGenerate {
|
||||||
|
generatorName.set("spring")
|
||||||
|
inputSpec.set("$projectDir/openapi/api.yaml")
|
||||||
|
outputDir.set("$projectDir/build/generated")
|
||||||
|
apiPackage.set("net.moustos.mtgsearch.api")
|
||||||
|
modelPackage.set("net.moustos.mtgsearch.model.api")
|
||||||
|
globalProperties.set(mapOf(
|
||||||
|
"apis" to "true",
|
||||||
|
"models" to "true"
|
||||||
|
))
|
||||||
|
configOptions.set(mapOf(
|
||||||
|
"delegatePattern" to "true",
|
||||||
|
"title" to "MTG Search API",
|
||||||
|
"interfaceOnly" to "false",
|
||||||
|
"skipDefaultInterface" to "false",
|
||||||
|
"useSpringBoot3" to "true",
|
||||||
|
"useJakartaEe" to "true"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
flyway {
|
||||||
|
url = project.property("db.url") as String
|
||||||
|
user = project.property("db.user") as String
|
||||||
|
password = project.property("db.password") as String
|
||||||
|
}
|
||||||
|
|
||||||
|
jooq {
|
||||||
|
version.set("$jooqVersion")
|
||||||
|
configurations {
|
||||||
|
create("main") {
|
||||||
|
jooqConfiguration.apply {
|
||||||
|
jdbc.apply {
|
||||||
|
driver = "org.postgresql.Driver"
|
||||||
|
url = project.property("db.url") as String
|
||||||
|
user = project.property("db.user") as String
|
||||||
|
password = project.property("db.password") as String
|
||||||
|
}
|
||||||
|
generator.apply {
|
||||||
|
name = "org.jooq.codegen.JavaGenerator"
|
||||||
|
database.apply {
|
||||||
|
name = "org.jooq.meta.postgres.PostgresDatabase"
|
||||||
|
inputSchema = "public"
|
||||||
|
}
|
||||||
|
target.apply {
|
||||||
|
packageName = "net.moustos.mtgsearch.jooq.generated"
|
||||||
|
directory = "$projectDir/build/generated/jooq"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPostgresAvailable(): Boolean {
|
||||||
|
val dbUrl = project.property("db.url") as String
|
||||||
|
val dbUser = project.property("db.user") as String
|
||||||
|
logger.lifecycle("Checking PostgreSQL availability at $dbUrl with user $dbUser")
|
||||||
|
return try {
|
||||||
|
// Load the PostgreSQL driver
|
||||||
|
Class.forName("org.postgresql.Driver")
|
||||||
|
DriverManager.setLoginTimeout(10)
|
||||||
|
DriverManager.getConnection(dbUrl, dbUser, project.property("db.password") as String).use { connection ->
|
||||||
|
logger.lifecycle("Successfully connected to PostgreSQL database.")
|
||||||
|
true
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
logger.error("Failed to connect to PostgreSQL at $dbUrl with user $dbUser: ${ex.message}")
|
||||||
|
logger.error("Exception details: ", ex)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("generateJooq") {
|
||||||
|
dependsOn("flywayMigrate")
|
||||||
|
onlyIf {
|
||||||
|
isPostgresAvailable()
|
||||||
|
}
|
||||||
|
doFirst {
|
||||||
|
logger.lifecycle("Starting jOOQ code generation...")
|
||||||
|
}
|
||||||
|
doLast {
|
||||||
|
logger.lifecycle("Finished jOOQ task.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("flywayMigrate") {
|
||||||
|
onlyIf {
|
||||||
|
isPostgresAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("compileJava") {
|
||||||
|
dependsOn("openApiGenerate")
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceSets["main"].java {
|
||||||
|
srcDir("$buildDir/generated/src/main/java")
|
||||||
|
srcDir("$buildDir/generated/jooq")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile> {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
options.compilerArgs.add("-parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
testLogging {
|
||||||
|
events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
|
||||||
|
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||||
|
showStandardStreams = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("build") {
|
||||||
|
dependsOn("openApiGenerate")
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
info:
|
||||||
|
title: MTG Search API
|
||||||
|
version: 0.1.0
|
||||||
|
description: Magic The Gathering Card Search REST API
|
||||||
|
contact:
|
||||||
|
name: API Support
|
||||||
|
url: https://github.com/example/mtg-search
|
||||||
|
license:
|
||||||
|
name: MIT
|
||||||
|
|
||||||
|
servers:
|
||||||
|
- url: http://localhost:8080
|
||||||
|
description: Development server
|
||||||
|
- url: https://api.mtgsearch.example.com
|
||||||
|
description: Production server
|
||||||
|
|
||||||
|
paths:
|
||||||
|
/api/v1/auth/register:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
summary: Register a new user
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RegisterRequest'
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: User registered successfully
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserResponse'
|
||||||
|
'400':
|
||||||
|
description: Invalid input
|
||||||
|
'409':
|
||||||
|
description: User already exists
|
||||||
|
|
||||||
|
/api/v1/auth/login:
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- Authentication
|
||||||
|
summary: Login user
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginRequest'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Login successful
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/LoginResponse'
|
||||||
|
'401':
|
||||||
|
description: Invalid credentials
|
||||||
|
'500':
|
||||||
|
description: Server error
|
||||||
|
|
||||||
|
/api/v1/auth/health:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- Health
|
||||||
|
summary: Health check
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Service is healthy
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/HealthResponse'
|
||||||
|
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
RegisterRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- email
|
||||||
|
- password
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
minLength: 3
|
||||||
|
maxLength: 100
|
||||||
|
example: john_doe
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
format: email
|
||||||
|
example: john@example.com
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
minLength: 8
|
||||||
|
example: SecurePassword123!
|
||||||
|
|
||||||
|
LoginRequest:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
example: john_doe
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
format: password
|
||||||
|
example: SecurePassword123!
|
||||||
|
|
||||||
|
UserResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
LoginResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
description: JWT token for authentication
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
error:
|
||||||
|
type: string
|
||||||
|
|
||||||
|
HealthResponse:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- ok
|
||||||
|
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package net.moustos.mtgsearch;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.context.annotation.ComponentScan;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main Spring Boot Application entry point for MTG Search
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
@ComponentScan(basePackages = {"net.moustos.mtgsearch"})
|
||||||
|
public class MtgSearchApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(MtgSearchApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package net.moustos.mtgsearch.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* General application configuration
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class AppConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "http://localhost:5173"));
|
||||||
|
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||||
|
configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "Accept"));
|
||||||
|
configuration.setAllowCredentials(true);
|
||||||
|
configuration.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package net.moustos.mtgsearch.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import net.moustos.mtgsearch.security.JwtAuthenticationFilter;
|
||||||
|
import net.moustos.mtgsearch.service.UserDetailsServiceImpl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security configuration
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final UserDetailsServiceImpl userDetailsService;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
|
||||||
|
public SecurityConfig(UserDetailsServiceImpl userDetailsService, PasswordEncoder passwordEncoder,
|
||||||
|
JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||||
|
this.userDetailsService = userDetailsService;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
|
||||||
|
AuthenticationManagerBuilder authenticationManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
|
||||||
|
authenticationManagerBuilder
|
||||||
|
.userDetailsService(userDetailsService)
|
||||||
|
.passwordEncoder(passwordEncoder);
|
||||||
|
return authenticationManagerBuilder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.cors(cors -> cors.disable())
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.exceptionHandling(exceptionHandling -> exceptionHandling
|
||||||
|
.authenticationEntryPoint((request, response, authException) -> {
|
||||||
|
response.setStatus(401);
|
||||||
|
response.setContentType("application/json");
|
||||||
|
response.getWriter().write("{\"error\": \"Unauthorized\"}");
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.sessionManagement(sessionManagement -> sessionManagement
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
|
)
|
||||||
|
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
|
||||||
|
.requestMatchers("/api/v1/auth/**").permitAll()
|
||||||
|
.requestMatchers("/api/v1/public/**").permitAll()
|
||||||
|
.requestMatchers("/swagger-ui/**", "/javadoc/**", "/v3/api-docs/**").permitAll()
|
||||||
|
.requestMatchers("/health", "/health/**").permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package net.moustos.mtgsearch.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web MVC configuration for serving static resources
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
|
registry.addResourceHandler("/**")
|
||||||
|
.addResourceLocations("classpath:/static/")
|
||||||
|
.setCacheControl(org.springframework.http.CacheControl.maxAge(365, java.util.concurrent.TimeUnit.DAYS));
|
||||||
|
|
||||||
|
registry.addResourceHandler("/index.html")
|
||||||
|
.addResourceLocations("classpath:/static/index.html");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package net.moustos.mtgsearch.controller;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import net.moustos.mtgsearch.model.User;
|
||||||
|
import net.moustos.mtgsearch.service.AuthService;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication controller for login and registration endpoints
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/auth")
|
||||||
|
@CrossOrigin(origins = {"http://localhost:3000", "http://localhost:5173"})
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
public AuthController(AuthService authService) {
|
||||||
|
this.authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
@PostMapping("/register")
|
||||||
|
public ResponseEntity<?> register(@RequestBody RegisterRequest request) {
|
||||||
|
try {
|
||||||
|
User user = authService.register(request.getUsername(), request.getEmail(), request.getPassword());
|
||||||
|
return ResponseEntity.status(HttpStatus.CREATED).body(Map.of(
|
||||||
|
"id", user.getId(),
|
||||||
|
"username", user.getUsername(),
|
||||||
|
"email", user.getEmail(),
|
||||||
|
"message", "User registered successfully"
|
||||||
|
));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of(
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
|
||||||
|
"error", "Registration failed"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login user and return JWT token
|
||||||
|
*/
|
||||||
|
@PostMapping("/login")
|
||||||
|
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||||
|
try {
|
||||||
|
String token = authService.authenticate(request.getUsername(), request.getPassword());
|
||||||
|
User user = authService.getUserByUsername(request.getUsername()).orElseThrow();
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"token", token,
|
||||||
|
"id", user.getId(),
|
||||||
|
"username", user.getUsername(),
|
||||||
|
"email", user.getEmail()
|
||||||
|
));
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Map.of(
|
||||||
|
"error", e.getMessage()
|
||||||
|
));
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of(
|
||||||
|
"error", "Login failed"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check endpoint
|
||||||
|
*/
|
||||||
|
@GetMapping("/health")
|
||||||
|
public ResponseEntity<Map<String, String>> health() {
|
||||||
|
return ResponseEntity.ok(Map.of("status", "ok"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTOs
|
||||||
|
public static class RegisterRequest {
|
||||||
|
public String username;
|
||||||
|
public String email;
|
||||||
|
public String password;
|
||||||
|
|
||||||
|
public RegisterRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public RegisterRequest(String username, String email, String password) {
|
||||||
|
this.username = username;
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmail() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LoginRequest {
|
||||||
|
public String username;
|
||||||
|
public String password;
|
||||||
|
|
||||||
|
public LoginRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoginRequest(String username, String password) {
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package net.moustos.mtgsearch.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User entity for authentication
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class User {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 100)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 100)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 255)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Boolean active;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void prePersist() {
|
||||||
|
createdAt = LocalDateTime.now();
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
active = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreUpdate
|
||||||
|
public void preUpdate() {
|
||||||
|
updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package net.moustos.mtgsearch.repository;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import net.moustos.mtgsearch.model.User;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User repository for database operations
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
Optional<User> findByUsername(String username);
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
boolean existsByUsername(String username);
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package net.moustos.mtgsearch.security;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
import net.moustos.mtgsearch.service.UserDetailsServiceImpl;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Authentication filter to validate tokens on each request
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
private final UserDetailsServiceImpl userDetailsService;
|
||||||
|
|
||||||
|
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsServiceImpl userDetailsService) {
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
this.userDetailsService = userDetailsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
try {
|
||||||
|
String jwt = extractJwtFromRequest(request);
|
||||||
|
|
||||||
|
if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
|
||||||
|
String username = jwtTokenProvider.validateTokenAndGetUsername(jwt);
|
||||||
|
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
|
|
||||||
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
|
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
logger.error("Could not set user authentication in security context", ex);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractJwtFromRequest(HttpServletRequest request) {
|
||||||
|
String bearerToken = request.getHeader("Authorization");
|
||||||
|
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
|
||||||
|
return bearerToken.substring(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package net.moustos.mtgsearch.security;
|
||||||
|
|
||||||
|
import com.auth0.jwt.JWT;
|
||||||
|
import com.auth0.jwt.algorithms.Algorithm;
|
||||||
|
import com.auth0.jwt.exceptions.JWTVerificationException;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Token provider for generating and validating JWT tokens
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class JwtTokenProvider {
|
||||||
|
|
||||||
|
@Value("${app.jwt.secret:your-secret-key-change-in-production}")
|
||||||
|
private String jwtSecret;
|
||||||
|
|
||||||
|
@Value("${app.jwt.expiration:86400}")
|
||||||
|
private long jwtExpiration;
|
||||||
|
|
||||||
|
private static final String CLAIM_USER_ID = "userId";
|
||||||
|
private static final String CLAIM_USERNAME = "username";
|
||||||
|
private static final String CLAIM_EMAIL = "email";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token for a user
|
||||||
|
*/
|
||||||
|
public String generateToken(long userId, String username, String email) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
Instant expiresAt = now.plus(jwtExpiration, ChronoUnit.SECONDS);
|
||||||
|
|
||||||
|
return JWT.create()
|
||||||
|
.withClaim(CLAIM_USER_ID, userId)
|
||||||
|
.withClaim(CLAIM_USERNAME, username)
|
||||||
|
.withClaim(CLAIM_EMAIL, email)
|
||||||
|
.withIssuedAt(now)
|
||||||
|
.withExpiresAt(expiresAt)
|
||||||
|
.withIssuer("mtg-search")
|
||||||
|
.sign(Algorithm.HMAC256(jwtSecret));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate JWT token and extract user ID
|
||||||
|
*/
|
||||||
|
public long validateTokenAndGetUserId(String token) throws JWTVerificationException {
|
||||||
|
return JWT.require(Algorithm.HMAC256(jwtSecret))
|
||||||
|
.withIssuer("mtg-search")
|
||||||
|
.build()
|
||||||
|
.verify(token)
|
||||||
|
.getClaim(CLAIM_USER_ID)
|
||||||
|
.asLong();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate JWT token and extract username
|
||||||
|
*/
|
||||||
|
public String validateTokenAndGetUsername(String token) throws JWTVerificationException {
|
||||||
|
return JWT.require(Algorithm.HMAC256(jwtSecret))
|
||||||
|
.withIssuer("mtg-search")
|
||||||
|
.build()
|
||||||
|
.verify(token)
|
||||||
|
.getClaim(CLAIM_USERNAME)
|
||||||
|
.asString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate JWT token
|
||||||
|
*/
|
||||||
|
public boolean validateToken(String token) {
|
||||||
|
try {
|
||||||
|
JWT.require(Algorithm.HMAC256(jwtSecret))
|
||||||
|
.withIssuer("mtg-search")
|
||||||
|
.build()
|
||||||
|
.verify(token);
|
||||||
|
return true;
|
||||||
|
} catch (JWTVerificationException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package net.moustos.mtgsearch.service;
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import net.moustos.mtgsearch.model.User;
|
||||||
|
import net.moustos.mtgsearch.repository.UserRepository;
|
||||||
|
import net.moustos.mtgsearch.security.JwtTokenProvider;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication service for user login and registration
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
public AuthService(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtTokenProvider jwtTokenProvider) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new user
|
||||||
|
*/
|
||||||
|
public User register(String username, String email, String password) {
|
||||||
|
if (userRepository.existsByUsername(username)) {
|
||||||
|
throw new IllegalArgumentException("Username already exists");
|
||||||
|
}
|
||||||
|
if (userRepository.existsByEmail(email)) {
|
||||||
|
throw new IllegalArgumentException("Email already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = User.builder()
|
||||||
|
.username(username)
|
||||||
|
.email(email)
|
||||||
|
.password(passwordEncoder.encode(password))
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate user and return JWT token
|
||||||
|
*/
|
||||||
|
public String authenticate(String username, String password) {
|
||||||
|
Optional<User> userOpt = userRepository.findByUsername(username);
|
||||||
|
if (userOpt.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("Invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = userOpt.get();
|
||||||
|
if (!user.getActive()) {
|
||||||
|
throw new IllegalArgumentException("User account is inactive");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordEncoder.matches(password, user.getPassword())) {
|
||||||
|
throw new IllegalArgumentException("Invalid username or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwtTokenProvider.generateToken(user.getId(), user.getUsername(), user.getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by ID
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<User> getUserById(Long userId) {
|
||||||
|
return userRepository.findById(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by username
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<User> getUserByUsername(String username) {
|
||||||
|
return userRepository.findByUsername(username);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package net.moustos.mtgsearch.service;
|
||||||
|
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import net.moustos.mtgsearch.repository.UserRepository;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User details service for Spring Security
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class UserDetailsServiceImpl implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public UserDetailsServiceImpl(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
var user = userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
|
||||||
|
|
||||||
|
boolean active = Boolean.TRUE.equals(user.getActive());
|
||||||
|
|
||||||
|
return User.builder()
|
||||||
|
.username(user.getUsername())
|
||||||
|
.password(user.getPassword())
|
||||||
|
.authorities(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")))
|
||||||
|
.accountLocked(!active)
|
||||||
|
.accountExpired(false)
|
||||||
|
.credentialsExpired(false)
|
||||||
|
.disabled(!active)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: ${app.name:mtg-search}
|
||||||
|
version: ${app.version:0.1.0}
|
||||||
|
|
||||||
|
datasource:
|
||||||
|
url: ${db.url:jdbc:postgresql://localhost:5432/mtgsearch}
|
||||||
|
username: ${db.user:postgres}
|
||||||
|
password: ${db.password:postgres}
|
||||||
|
hikari:
|
||||||
|
maximum-pool-size: ${db.maxPoolSize:10}
|
||||||
|
minimum-idle: 2
|
||||||
|
idle-timeout: 300000
|
||||||
|
max-lifetime: 1200000
|
||||||
|
auto-commit: true
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate.format_sql: true
|
||||||
|
hibernate.jdbc.batch_size: 20
|
||||||
|
hibernate.order_inserts: true
|
||||||
|
hibernate.order_updates: true
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
baseline-on-migrate: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
sql-migration-prefix: V
|
||||||
|
sql-migration-suffix: .sql
|
||||||
|
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-request-size: 10MB
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
servlet:
|
||||||
|
context-path: /
|
||||||
|
compression:
|
||||||
|
enabled: true
|
||||||
|
min-response-size: 1024
|
||||||
|
|
||||||
|
app:
|
||||||
|
jwt:
|
||||||
|
secret: ${jwt.secret:your-secret-key-change-in-production}
|
||||||
|
expiration: ${jwt.expiration:86400}
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: ${log.level:INFO}
|
||||||
|
net.moustos.mtgsearch: DEBUG
|
||||||
|
org.springframework.web: INFO
|
||||||
|
org.springframework.security: DEBUG
|
||||||
|
config: classpath:logback-spring.xml
|
||||||
|
file:
|
||||||
|
name: ${log.dir:logs}/app.log
|
||||||
|
max-size: 100MB
|
||||||
|
max-history: 30
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
path: /v3/api-docs
|
||||||
|
swagger-ui:
|
||||||
|
path: /swagger-ui.html
|
||||||
|
enabled: true
|
||||||
|
operations-sorter: method
|
||||||
|
tags-sorter: alpha
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Initial schema setup for MTG Search
|
||||||
|
-- Users table for authentication
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
username VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
email VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create indexes for common queries
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
|
CREATE INDEX idx_users_email ON users(email);
|
||||||
|
CREATE INDEX idx_users_active ON users(active);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration>
|
||||||
|
<property name="LOG_DIR" value="logs"/>
|
||||||
|
<property name="LOG_FILE_NAME" value="mtg-search"/>
|
||||||
|
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
|
||||||
|
|
||||||
|
<!-- Console appender -->
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>${LOG_PATTERN}</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- Rolling file appender -->
|
||||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_DIR}/${LOG_FILE_NAME}.log</file>
|
||||||
|
<encoder>
|
||||||
|
<pattern>${LOG_PATTERN}</pattern>
|
||||||
|
</encoder>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||||
|
<maxFileSize>100MB</maxFileSize>
|
||||||
|
<maxHistory>30</maxHistory>
|
||||||
|
<totalSizeCap>3GB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- Error file appender -->
|
||||||
|
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>${LOG_DIR}/${LOG_FILE_NAME}-error.log</file>
|
||||||
|
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||||
|
<level>ERROR</level>
|
||||||
|
<onMatch>ACCEPT</onMatch>
|
||||||
|
<onMismatch>DENY</onMismatch>
|
||||||
|
</filter>
|
||||||
|
<encoder>
|
||||||
|
<pattern>${LOG_PATTERN}</pattern>
|
||||||
|
</encoder>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>${LOG_DIR}/${LOG_FILE_NAME}-error-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
|
||||||
|
<maxFileSize>100MB</maxFileSize>
|
||||||
|
<maxHistory>30</maxHistory>
|
||||||
|
<totalSizeCap>3GB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- Root logger -->
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
<appender-ref ref="FILE"/>
|
||||||
|
<appender-ref ref="ERROR_FILE"/>
|
||||||
|
</root>
|
||||||
|
|
||||||
|
<!-- Application loggers -->
|
||||||
|
<logger name="net.moustos.mtgsearch" level="DEBUG"/>
|
||||||
|
<logger name="org.springframework" level="INFO"/>
|
||||||
|
<logger name="org.springframework.security" level="DEBUG"/>
|
||||||
|
<logger name="org.hibernate" level="WARN"/>
|
||||||
|
<logger name="org.flywaydb" level="INFO"/>
|
||||||
|
</configuration>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package net.moustos.mtgsearch;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import net.moustos.mtgsearch.repository.UserRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for authentication API
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
@TestPropertySource(locations = "classpath:application-test.yml")
|
||||||
|
public class AuthControllerIntegrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setUp() {
|
||||||
|
// Clear database before each test
|
||||||
|
userRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUserRegistration() throws Exception {
|
||||||
|
String registerPayload = """
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "SecurePassword123!"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/register")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(registerPayload))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.username").value("testuser"))
|
||||||
|
.andExpect(jsonPath("$.email").value("test@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testUserLogin() throws Exception {
|
||||||
|
// First register
|
||||||
|
String registerPayload = """
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "SecurePassword123!"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/register")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(registerPayload))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
// Then login
|
||||||
|
String loginPayload = """
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"password": "SecurePassword123!"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(loginPayload))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.token").exists())
|
||||||
|
.andExpect(jsonPath("$.username").value("testuser"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testLoginWithInvalidCredentials() throws Exception {
|
||||||
|
String loginPayload = """
|
||||||
|
{
|
||||||
|
"username": "nonexistent",
|
||||||
|
"password": "WrongPassword123!"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(loginPayload))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDuplicateUsernameRegistration() throws Exception {
|
||||||
|
String registerPayload = """
|
||||||
|
{
|
||||||
|
"username": "testuser",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"password": "SecurePassword123!"
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
// Register first user
|
||||||
|
mockMvc.perform(post("/api/v1/auth/register")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(registerPayload))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
// Try to register with same username
|
||||||
|
mockMvc.perform(post("/api/v1/auth/register")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(registerPayload))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package net.moustos.mtgsearch.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
import net.moustos.mtgsearch.model.User;
|
||||||
|
import net.moustos.mtgsearch.repository.UserRepository;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Disabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for AuthService
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Testcontainers
|
||||||
|
@Disabled("Requires Docker for Testcontainers")
|
||||||
|
@DisplayName("AuthService Tests")
|
||||||
|
public class AuthServiceTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")
|
||||||
|
.withDatabaseName("mtgsearch_test")
|
||||||
|
.withUsername("postgres")
|
||||||
|
.withPassword("postgres");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", postgres::getUsername);
|
||||||
|
registry.add("spring.datasource.password", postgres::getPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AuthService authService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setUp() {
|
||||||
|
userRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should register a new user successfully")
|
||||||
|
public void testRegisterUser() {
|
||||||
|
User user = authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||||
|
assertNotNull(user.getId());
|
||||||
|
assertEquals("testuser", user.getUsername());
|
||||||
|
assertEquals("test@example.com", user.getEmail());
|
||||||
|
assertTrue(user.getActive());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should throw exception for duplicate username")
|
||||||
|
public void testRegisterDuplicateUsername() {
|
||||||
|
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
authService.register("testuser", "another@example.com", "SecurePassword123!")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should throw exception for duplicate email")
|
||||||
|
public void testRegisterDuplicateEmail() {
|
||||||
|
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
authService.register("anotheruser", "test@example.com", "SecurePassword123!")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should authenticate user successfully")
|
||||||
|
public void testAuthenticateUser() {
|
||||||
|
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||||
|
String token = authService.authenticate("testuser", "SecurePassword123!");
|
||||||
|
assertNotNull(token);
|
||||||
|
assertFalse(token.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should throw exception for invalid username")
|
||||||
|
public void testAuthenticateWithInvalidUsername() {
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
authService.authenticate("nonexistent", "SomePassword123!")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should throw exception for invalid password")
|
||||||
|
public void testAuthenticateWithInvalidPassword() {
|
||||||
|
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||||
|
assertThrows(IllegalArgumentException.class, () ->
|
||||||
|
authService.authenticate("testuser", "WrongPassword123!")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should retrieve user by ID")
|
||||||
|
public void testGetUserById() {
|
||||||
|
User registeredUser = authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||||
|
var retrievedUser = authService.getUserById(registeredUser.getId());
|
||||||
|
assertTrue(retrievedUser.isPresent());
|
||||||
|
assertEquals(registeredUser.getId(), retrievedUser.get().getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should retrieve user by username")
|
||||||
|
public void testGetUserByUsername() {
|
||||||
|
authService.register("testuser", "test@example.com", "SecurePassword123!");
|
||||||
|
var retrievedUser = authService.getUserByUsername("testuser");
|
||||||
|
assertTrue(retrievedUser.isPresent());
|
||||||
|
assertEquals("testuser", retrievedUser.get().getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package net.moustos.mtgsearch.repository;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
import net.moustos.mtgsearch.model.User;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Disabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for UserRepository
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@Testcontainers
|
||||||
|
@Disabled("Requires Docker for Testcontainers")
|
||||||
|
public class UserRepositoryTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine")
|
||||||
|
.withDatabaseName("mtgsearch_test")
|
||||||
|
.withUsername("postgres")
|
||||||
|
.withPassword("postgres");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", postgres::getUsername);
|
||||||
|
registry.add("spring.datasource.password", postgres::getPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
public void setUp() {
|
||||||
|
userRepository.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFindByUsername() {
|
||||||
|
User user = User.builder()
|
||||||
|
.username("testuser")
|
||||||
|
.email("test@example.com")
|
||||||
|
.password("hashedpassword")
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
var found = userRepository.findByUsername("testuser");
|
||||||
|
assertTrue(found.isPresent());
|
||||||
|
assertEquals("testuser", found.get().getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFindByEmail() {
|
||||||
|
User user = User.builder()
|
||||||
|
.username("testuser")
|
||||||
|
.email("test@example.com")
|
||||||
|
.password("hashedpassword")
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
var found = userRepository.findByEmail("test@example.com");
|
||||||
|
assertTrue(found.isPresent());
|
||||||
|
assertEquals("test@example.com", found.get().getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testExistsByUsername() {
|
||||||
|
User user = User.builder()
|
||||||
|
.username("testuser")
|
||||||
|
.email("test@example.com")
|
||||||
|
.password("hashedpassword")
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
assertTrue(userRepository.existsByUsername("testuser"));
|
||||||
|
assertFalse(userRepository.existsByUsername("nonexistent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testExistsByEmail() {
|
||||||
|
User user = User.builder()
|
||||||
|
.username("testuser")
|
||||||
|
.email("test@example.com")
|
||||||
|
.password("hashedpassword")
|
||||||
|
.active(true)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
assertTrue(userRepository.existsByEmail("test@example.com"));
|
||||||
|
assertFalse(userRepository.existsByEmail("nonexistent@example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
spring:
|
||||||
|
profiles:
|
||||||
|
active: test
|
||||||
|
datasource:
|
||||||
|
url: jdbc:h2:mem:testdb
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.H2Dialect
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: create-drop
|
||||||
|
flyway:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
app:
|
||||||
|
jwt:
|
||||||
|
secret: test-secret-key
|
||||||
|
expiration: 3600
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
net.moustos.mtgsearch: DEBUG
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'MTGDBPlugin'
|
|
||||||
id 'java'
|
|
||||||
id 'org.springframework.boot' version '3.5.6'
|
|
||||||
id 'io.spring.dependency-management' version '1.1.7'
|
|
||||||
id 'com.vaadin' version '24.9.3'
|
|
||||||
}
|
|
||||||
|
|
||||||
group = 'com.example'
|
|
||||||
version = '0.0.1-SNAPSHOT'
|
|
||||||
description = 'Demo project for Spring Boot'
|
|
||||||
|
|
||||||
java {
|
|
||||||
toolchain {
|
|
||||||
languageVersion = JavaLanguageVersion.of(21)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
ext {
|
|
||||||
set('vaadinVersion', "24.9.3")
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation 'com.vaadin:vaadin-spring-boot-starter'
|
|
||||||
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
|
|
||||||
runtimeOnly 'org.postgresql:postgresql'
|
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencyManagement {
|
|
||||||
imports {
|
|
||||||
mavenBom "com.vaadin:vaadin-bom:${vaadinVersion}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.named('test') {
|
|
||||||
useJUnitPlatform()
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import org.gradle.api.tasks.testing.logging.TestLogEvent
|
||||||
|
import java.util.Properties
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("java")
|
||||||
|
id("org.springframework.boot") version "4.0.+" apply false
|
||||||
|
id("io.spring.dependency-management") version "1.1.6"
|
||||||
|
id("org.sonarqube") version "7.2.3.7755"
|
||||||
|
}
|
||||||
|
|
||||||
|
sonar {
|
||||||
|
properties {
|
||||||
|
property("sonar.projectKey", "MTGSearch")
|
||||||
|
property("sonar.projectName", "MTGSearch")
|
||||||
|
property("sonar.qualitygate.wait", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
group = "net.moustos"
|
||||||
|
version = "0.1.0"
|
||||||
|
|
||||||
|
// Load dev.properties if it exists
|
||||||
|
val devPropsFile = File("dev.properties")
|
||||||
|
val devProps = Properties().apply {
|
||||||
|
if (devPropsFile.exists()) {
|
||||||
|
load(devPropsFile.inputStream())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define properties with defaults, preferring env vars over dev.properties
|
||||||
|
val propertiesMap = mapOf(
|
||||||
|
"db.url" to (System.getenv("DB_URL") ?: devProps.getProperty("db.url", "jdbc:postgresql://localhost:5432/mtgsearch")),
|
||||||
|
"db.user" to (System.getenv("DB_USER") ?: devProps.getProperty("db.user", "postgres")),
|
||||||
|
"db.password" to (System.getenv("DB_PASSWORD") ?: devProps.getProperty("db.password", "postgres")),
|
||||||
|
"db.maxPoolSize" to (System.getenv("DB_MAX_POOL_SIZE") ?: devProps.getProperty("db.maxPoolSize", "10")),
|
||||||
|
"jwt.secret" to (System.getenv("JWT_SECRET") ?: devProps.getProperty("jwt.secret", "your-secret-key-change-in-production")),
|
||||||
|
"jwt.expiration" to (System.getenv("JWT_EXPIRATION") ?: devProps.getProperty("jwt.expiration", "86400")),
|
||||||
|
"app.name" to (System.getenv("APP_NAME") ?: devProps.getProperty("app.name", "mtg-search")),
|
||||||
|
"app.version" to (System.getenv("APP_VERSION") ?: devProps.getProperty("app.version", "0.1.0")),
|
||||||
|
"app.environment" to (System.getenv("APP_ENVIRONMENT") ?: devProps.getProperty("app.environment", "development")),
|
||||||
|
"server.port" to (System.getenv("SERVER_PORT") ?: devProps.getProperty("server.port", "8080")),
|
||||||
|
"server.servlet.contextPath" to (System.getenv("SERVER_SERVLET_CONTEXT_PATH") ?: devProps.getProperty("server.servlet.contextPath", "/")),
|
||||||
|
"vite.apiUrl" to (System.getenv("VITE_API_URL") ?: devProps.getProperty("vite.apiUrl", "http://localhost:8080")),
|
||||||
|
"vite.appTitle" to (System.getenv("VITE_APP_TITLE") ?: devProps.getProperty("vite.appTitle", "MTG Search")),
|
||||||
|
"log.level" to (System.getenv("LOG_LEVEL") ?: devProps.getProperty("log.level", "DEBUG")),
|
||||||
|
"log.dir" to (System.getenv("LOG_DIR") ?: devProps.getProperty("log.dir", "logs"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set system properties for Spring Boot
|
||||||
|
propertiesMap.forEach { (key, value) ->
|
||||||
|
System.setProperty(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also set as project extra for Gradle use
|
||||||
|
propertiesMap.forEach { (key, value) ->
|
||||||
|
extra.set(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
url = uri("https://repo.maven.apache.org/maven2/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
configure(subprojects.filter { it.name == "backend" }) {
|
||||||
|
apply(plugin = "java")
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile> {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
options.compilerArgs.add("-parameters")
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
testLogging {
|
||||||
|
events(TestLogEvent.PASSED, TestLogEvent.SKIPPED, TestLogEvent.FAILED)
|
||||||
|
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||||
|
showStandardStreams = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Developer helper tasks
|
||||||
|
tasks.register("createDevProperties") {
|
||||||
|
group = "developer"
|
||||||
|
description = "Creates dev.properties file with default values if it doesn't exist"
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
val devPropsFile = File("dev.properties")
|
||||||
|
if (!devPropsFile.exists()) {
|
||||||
|
val defaultContent = """
|
||||||
|
# Development properties
|
||||||
|
# This file contains configuration values for build and runtime.
|
||||||
|
# Do not commit this file to the repository.
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
db.url=jdbc:postgresql://localhost:5432/mtgsearch
|
||||||
|
db.user=postgres
|
||||||
|
db.password=postgres
|
||||||
|
db.maxPoolSize=10
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
jwt.secret=your-secret-key-change-in-production
|
||||||
|
jwt.expiration=86400
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
app.name=mtg-search
|
||||||
|
app.version=0.1.0
|
||||||
|
app.environment=development
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
server.port=8080
|
||||||
|
server.servlet.contextPath=/
|
||||||
|
|
||||||
|
# Frontend Configuration
|
||||||
|
vite.apiUrl=http://localhost:8080
|
||||||
|
vite.appTitle=MTG Search
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
log.level=DEBUG
|
||||||
|
log.dir=logs
|
||||||
|
""".trimIndent()
|
||||||
|
devPropsFile.writeText(defaultContent)
|
||||||
|
println("Created dev.properties with default values")
|
||||||
|
} else {
|
||||||
|
println("dev.properties already exists")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Exec>("startPostgres") {
|
||||||
|
group = "developer"
|
||||||
|
description = "Starts the PostgreSQL container using docker-compose"
|
||||||
|
|
||||||
|
doFirst {
|
||||||
|
// Check if Docker is available
|
||||||
|
val dockerCheck = providers.exec {
|
||||||
|
commandLine("docker", "info")
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}
|
||||||
|
if (dockerCheck.result.get().exitValue != 0) {
|
||||||
|
throw GradleException("""
|
||||||
|
Docker is not available or not running. Please ensure:
|
||||||
|
1. Docker daemon is running
|
||||||
|
2. You have permission to access Docker (try 'sudo usermod -aG docker ${'$'}USER' and restart your session)
|
||||||
|
3. Docker Compose is installed
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
println("Starting PostgreSQL container...")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLine("docker", "compose", "up", "-d", "postgres")
|
||||||
|
workingDir = project.rootDir
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
println("PostgreSQL container started. Waiting for it to be healthy...")
|
||||||
|
// Optional: wait for health check
|
||||||
|
Thread.sleep(5000) // Simple wait, could be improved
|
||||||
|
println("PostgreSQL should be ready now.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Exec>("stopPostgres") {
|
||||||
|
group = "developer"
|
||||||
|
description = "Stops the PostgreSQL container using docker-compose"
|
||||||
|
|
||||||
|
doFirst {
|
||||||
|
// Check if Docker is available
|
||||||
|
val dockerCheck = providers.exec {
|
||||||
|
commandLine("docker", "info")
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}
|
||||||
|
if (dockerCheck.result.get().exitValue != 0) {
|
||||||
|
throw GradleException("""
|
||||||
|
Docker is not available or not running. Please ensure:
|
||||||
|
1. Docker daemon is running
|
||||||
|
2. You have permission to access Docker (try 'sudo usermod -aG docker ${'$'}USER' and restart your session)
|
||||||
|
3. Docker Compose is installed
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
println("Stopping PostgreSQL container...")
|
||||||
|
}
|
||||||
|
|
||||||
|
commandLine("docker", "compose", "down")
|
||||||
|
workingDir = project.rootDir
|
||||||
|
|
||||||
|
doLast {
|
||||||
|
println("PostgreSQL container stopped.")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'groovy-base'
|
|
||||||
id 'java-library'
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation gradleApi()
|
|
||||||
implementation localGroovy()
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import org.gradle.api.Plugin;
|
|
||||||
import org.gradle.api.Project;
|
|
||||||
import org.gradle.api.Task;
|
|
||||||
|
|
||||||
public class MTGDBPlugin implements Plugin<Project> {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void apply(Project target) {
|
|
||||||
Task javaTask = target.task("javaTask");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
implementation-class=MTGDBPlugin
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: 'postgres:latest'
|
|
||||||
environment:
|
|
||||||
- 'POSTGRES_DB=mtg'
|
|
||||||
- 'POSTGRES_PASSWORD=password'
|
|
||||||
- 'POSTGRES_USER=username'
|
|
||||||
ports:
|
|
||||||
- '5432:5432'
|
|
||||||
-155
@@ -1,155 +0,0 @@
|
|||||||
import java.util.zip.ZipEntry
|
|
||||||
import java.util.zip.ZipInputStream
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id 'java'
|
|
||||||
id 'com.google.cloud.tools.jib' version '3.4.2'
|
|
||||||
id 'application'
|
|
||||||
}
|
|
||||||
|
|
||||||
application {
|
|
||||||
mainClass = 'com.example.DbSetup'
|
|
||||||
}
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation 'org.postgresql:postgresql:42.7.3'
|
|
||||||
}
|
|
||||||
|
|
||||||
def containername = "mtg-search-db"
|
|
||||||
def dbName = "mtgsearch"
|
|
||||||
def dbUser = "dbuser"
|
|
||||||
def dbPass = "dbpass"
|
|
||||||
def psqlZipFile = new File("$rootDir\\AllPrintings.psql.zip")
|
|
||||||
def psqlFile = new File("$rootDir\\AllPrintings\\AllPrintings.psql")
|
|
||||||
|
|
||||||
jib {
|
|
||||||
from {
|
|
||||||
image = 'postgres:17' // base image
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
image = "hub.docker.com/$containername:latest"
|
|
||||||
auth {
|
|
||||||
username = System.getenv("DOCKER_USER")?:""
|
|
||||||
password = System.getenv("DOCKER_PASS")?:""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
container {
|
|
||||||
// Jib runs as rootless by default; PostgreSQL needs a few tweaks
|
|
||||||
entrypoint = ['docker-entrypoint.sh']
|
|
||||||
args = ['postgres']
|
|
||||||
environment = [
|
|
||||||
POSTGRES_USER: dbUser,
|
|
||||||
POSTGRES_PASSWORD: dbPass,
|
|
||||||
POSTGRES_DB: dbName
|
|
||||||
]
|
|
||||||
ports = ['5432']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom Gradle task to run database setup logic
|
|
||||||
tasks.register('runDbSetup', JavaExec) {
|
|
||||||
dependsOn classes
|
|
||||||
mainClass.set('com.example.DbSetup')
|
|
||||||
classpath = sourceSets.main.runtimeClasspath
|
|
||||||
environment 'POSTGRES_USER', dbUser
|
|
||||||
environment 'POSTGRES_PASSWORD', dbPass
|
|
||||||
environment 'POSTGRES_DB', dbName
|
|
||||||
environment 'PSQL_FILE', psqlFile.canonicalPath
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure setup runs before building the Docker image
|
|
||||||
tasks.named('jib') {
|
|
||||||
dependsOn('runDbSetup')
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register('startPostgresContainer', Exec) {
|
|
||||||
commandLine 'docker', 'run', '-d',
|
|
||||||
'--name', "$containername",
|
|
||||||
'-e', "POSTGRES_USER=$dbUser",
|
|
||||||
'-e', "POSTGRES_PASSWORD=$dbPass",
|
|
||||||
'-e', "POSTGRES_DB=$dbName",
|
|
||||||
'-p', '5432:5432',
|
|
||||||
'postgres:17'
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register('stopPostgresContainer', Exec) {
|
|
||||||
commandLine 'docker', 'rm', '-f', containername
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook the lifecycle
|
|
||||||
tasks.named('runDbSetup') {
|
|
||||||
dependsOn('startPostgresContainer')
|
|
||||||
finalizedBy('stopPostgresContainer')
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register('getLatestMTGJson') {
|
|
||||||
outputs.files files("AllPrintings.psql.zip.sha256","AllPrintings.psql.zip")
|
|
||||||
outputs.upToDateWhen {
|
|
||||||
def url = new URL("https://mtgjson.com/api/v5/AllPrintings.psql.zip.sha256")
|
|
||||||
def urlHash = url.text // Groovy shortcut to read text from URL
|
|
||||||
def file = new File('AllPrintings.psql.zip.sha256')
|
|
||||||
if(file.exists()) {
|
|
||||||
def fileHash = file.text
|
|
||||||
def equal = urlHash == fileHash
|
|
||||||
if(!equal){
|
|
||||||
println("AllPrintings.psql.zip.sha256 out of date")
|
|
||||||
}
|
|
||||||
return equal
|
|
||||||
}
|
|
||||||
println("AllPrintings.psql.zip.sha256 not found")
|
|
||||||
return false
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
doLast {
|
|
||||||
def url = new URL("https://mtgjson.com/api/v5/AllPrintings.psql.zip")
|
|
||||||
|
|
||||||
println("Downloading $url to $psqlZipFile")
|
|
||||||
psqlZipFile.withOutputStream { out ->
|
|
||||||
out << url.openStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
url = new URL("https://mtgjson.com/api/v5/AllPrintings.psql.zip.sha256")
|
|
||||||
def file = new File("AllPrintings.psql.zip.sha256")
|
|
||||||
println("Downloading $url to $file")
|
|
||||||
file.withOutputStream { out ->
|
|
||||||
out << url.openStream()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register("unzipAllPrintings"){
|
|
||||||
dependsOn("getLatestMTGJson")
|
|
||||||
doLast {
|
|
||||||
def destinationDir = new File("AllPrintings")
|
|
||||||
|
|
||||||
// Ensure the destination directory exists
|
|
||||||
destinationDir.mkdirs()
|
|
||||||
|
|
||||||
psqlZipFile.withInputStream { fis ->
|
|
||||||
new ZipInputStream(fis).with { zis ->
|
|
||||||
ZipEntry entry
|
|
||||||
while ((entry = zis.nextEntry) != null) {
|
|
||||||
def outputFile = new File(destinationDir, entry.name)
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
outputFile.mkdirs()
|
|
||||||
} else {
|
|
||||||
outputFile.parentFile.mkdirs() // Ensure parent directories exist
|
|
||||||
outputFile.withOutputStream { fos ->
|
|
||||||
fos << zis // Copy the entry's content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
zis.closeEntry()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println "Successfully unzipped ${psqlZipFile.name} to ${destinationDir.absolutePath}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
FROM postgres:17
|
|
||||||
COPY ./init.sql /docker-entrypoint-initdb.d/
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package com.example;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.sql.Connection;
|
|
||||||
import java.sql.DriverManager;
|
|
||||||
import java.sql.Statement;
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.StringJoiner;
|
|
||||||
|
|
||||||
public class DbSetup {
|
|
||||||
public static void main(String[] args) throws Exception {
|
|
||||||
|
|
||||||
// ========================
|
|
||||||
//todo replace with better way to wait for container to be ready
|
|
||||||
Thread.sleep(5000);
|
|
||||||
// ========================
|
|
||||||
String dbuser = Objects.requireNonNullElse(System.getenv("POSTGRES_USER"),"dbuser");
|
|
||||||
String dbpass = Objects.requireNonNullElse(System.getenv("POSTGRES_PASSWORD"),"dbpass");
|
|
||||||
String dbname = Objects.requireNonNullElse(System.getenv("POSTGRES_DB"),"mtgsearch");
|
|
||||||
String psqlFilePath = Objects.requireNonNullElse(System.getenv("PSQL_FILE"),"AllPrintings/AllPrintings.psql");
|
|
||||||
String url = "jdbc:postgresql://localhost:5432/" + dbname;
|
|
||||||
|
|
||||||
|
|
||||||
//Load the MTGJson Allprintings PSQL File
|
|
||||||
File psqlFile = new File(psqlFilePath);
|
|
||||||
|
|
||||||
String[] commands = {"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe","-U",dbuser,"-d",dbname,"-h","localhost","-p","5432","-f",psqlFile.getCanonicalPath()};
|
|
||||||
String[] envVars = { "PGPASSWORD="+dbpass };
|
|
||||||
Runtime rt = Runtime.getRuntime();
|
|
||||||
System.out.println(String.join(" ",commands));
|
|
||||||
Process proc = rt.exec(commands,envVars);
|
|
||||||
System.out.println("<OUTPUT>");
|
|
||||||
|
|
||||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream()))) {
|
|
||||||
String line;
|
|
||||||
while ((line = reader.readLine()) != null) {
|
|
||||||
System.out.println("Output: " + line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("</OUTPUT>");
|
|
||||||
System.out.println("<ERROR>");
|
|
||||||
|
|
||||||
try (BufferedReader errorReader = new BufferedReader(new InputStreamReader(proc.getErrorStream()))) {
|
|
||||||
String line;
|
|
||||||
while ((line = errorReader.readLine()) != null) {
|
|
||||||
System.err.println("Error: " + line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
System.out.println("</ERROR>");
|
|
||||||
int exitVal = proc.waitFor();
|
|
||||||
System.out.println("Process exitValue: " + exitVal);
|
|
||||||
|
|
||||||
|
|
||||||
// try (Connection conn = DriverManager.getConnection(url, dbuser, dbpass);
|
|
||||||
// Statement stmt = conn.createStatement()) {
|
|
||||||
// stmt.execute("CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT)");
|
|
||||||
// stmt.execute("INSERT INTO users (name) VALUES ('Alice'), ('Bob')");
|
|
||||||
// System.out.println("✅ Database setup complete!");
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: mtgsearch-db
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: mtgsearch
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: mtgsearch-backend
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/mtgsearch
|
||||||
|
SPRING_DATASOURCE_USERNAME: postgres
|
||||||
|
SPRING_DATASOURCE_PASSWORD: postgres
|
||||||
|
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-your-secret-key-change-in-production}
|
||||||
|
JWT_EXPIRATION: 86400
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/api/v1/auth/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
name: mtgsearch-network
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Frontend ESLint configuration
|
||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:vue/vue3-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parser": "vue-eslint-parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"vue",
|
||||||
|
"@typescript-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"vue/multi-word-component-names": "off",
|
||||||
|
"vue/no-setup-props-destructure": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-console": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
frontend/.eslintrc.json
|
||||||
|
frontend/.prettierrc
|
||||||
|
frontend/.gitignore
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
; Frontend Prettier configuration
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"vueIndentScriptAndStyle": true
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
declare module '*.vue' {
|
||||||
|
import { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>MTG Search</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "mtg-search-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
|
||||||
|
"type-check": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"pinia": "^2.2.0",
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"pinia-plugin-persistedstate": "^3.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
|
"vite": "^5.3.1",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vue-tsc": "^1.8.27",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"vitest": "^2.0.0",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-plugin-vue": "^9.26.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.8.0",
|
||||||
|
"@typescript-eslint/parser": "^7.8.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitkeep
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app" class="app">
|
||||||
|
<header class="navbar">
|
||||||
|
<div class="navbar-container">
|
||||||
|
<router-link to="/" class="navbar-brand">
|
||||||
|
<h1>MTG Search</h1>
|
||||||
|
</router-link>
|
||||||
|
<nav class="navbar-menu">
|
||||||
|
<router-link v-if="!authStore.isAuthenticated" to="/login" class="nav-link">Login</router-link>
|
||||||
|
<router-link v-if="!authStore.isAuthenticated" to="/register" class="nav-link">Register</router-link>
|
||||||
|
<router-link v-if="authStore.isAuthenticated" to="/dashboard" class="nav-link">Dashboard</router-link>
|
||||||
|
<button v-if="authStore.isAuthenticated" @click="logout" class="nav-link logout-btn">Logout</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<p>© 2024 MTG Search. All rights reserved.</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
padding: 0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
text-decoration: none;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-menu {
|
||||||
|
display: flex;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import createPersistedState from 'pinia-plugin-persistedstate'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
pinia.use(createPersistedState())
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard-page">
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<h1>Welcome, {{ authStore.user?.username }}!</h1>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Profile Information</h2>
|
||||||
|
<div class="profile-info">
|
||||||
|
<p><strong>Username:</strong> {{ authStore.user?.username }}</p>
|
||||||
|
<p><strong>Email:</strong> {{ authStore.user?.email }}</p>
|
||||||
|
<p><strong>ID:</strong> {{ authStore.user?.id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Quick Actions</h2>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-secondary">Search Cards</button>
|
||||||
|
<button class="btn btn-secondary">View Decks</button>
|
||||||
|
<button class="btn btn-secondary">Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Search Cards</h2>
|
||||||
|
<p style="color: #666; margin-bottom: 1rem;">Coming soon...</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search Magic The Gathering cards..."
|
||||||
|
class="search-input"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-container h1 {
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h2 {
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-info p {
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:disabled {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<div class="home-page">
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Welcome to MTG Search</h1>
|
||||||
|
<p>Search and explore Magic The Gathering cards</p>
|
||||||
|
<div class="hero-buttons" v-if="!authStore.isAuthenticated">
|
||||||
|
<router-link to="/login" class="btn btn-primary">Login</router-link>
|
||||||
|
<router-link to="/register" class="btn btn-secondary">Register</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="hero-buttons" v-else>
|
||||||
|
<router-link to="/dashboard" class="btn btn-primary">Go to Dashboard</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.home-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
animation: slideIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #0f0f1e;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #c0392b;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-container">
|
||||||
|
<h1>Login to MTG Search</h1>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
v-model="form.username"
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Enter your username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
|
{{ loading ? 'Logging in...' : 'Login' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="signup-link">
|
||||||
|
Don't have an account? <router-link to="/register">Register here</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.login(form.username, form.password)
|
||||||
|
router.push('/dashboard')
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || 'Login failed. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 70vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a1a2e;
|
||||||
|
box-shadow: 0 0 0 3px rgba(26, 26, 46, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: #0f0f1e;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-link a {
|
||||||
|
color: #1a1a2e;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signup-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<template>
|
||||||
|
<div class="register-page">
|
||||||
|
<div class="register-container">
|
||||||
|
<h1>Create Account</h1>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleRegister">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
v-model="form.username"
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
placeholder="Choose a username"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Enter your email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Create a password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirmPassword">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
v-model="form.confirmPassword"
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm your password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="success" class="alert alert-success">
|
||||||
|
{{ success }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||||
|
{{ loading ? 'Creating account...' : 'Register' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="login-link">
|
||||||
|
Already have an account? <router-link to="/login">Login here</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
|
||||||
|
if (form.value.password !== form.value.confirmPassword) {
|
||||||
|
error.value = 'Passwords do not match'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
await authStore.register(form.value.username, form.value.email, form.value.password)
|
||||||
|
success.value = 'Account created successfully! Redirecting to login...'
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login')
|
||||||
|
}, 2000)
|
||||||
|
} catch (err: any) {
|
||||||
|
error.value = err.response?.data?.error || 'Registration failed. Please try again.'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.register-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 75vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.register-container h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #1a1a2e;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #1a1a2e;
|
||||||
|
box-shadow: 0 0 0 3px rgba(26, 26, 46, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background-color: #0f0f1e;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link a {
|
||||||
|
color: #1a1a2e;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import Home from '@/pages/Home.vue'
|
||||||
|
import Login from '@/pages/Login.vue'
|
||||||
|
import Register from '@/pages/Register.vue'
|
||||||
|
import Dashboard from '@/pages/Dashboard.vue'
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: Home,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: Login,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'Register',
|
||||||
|
component: Register,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: Dashboard,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||||
|
next('/login')
|
||||||
|
} else if ((to.path === '/login' || to.path === '/register') && authStore.isAuthenticated) {
|
||||||
|
next('/dashboard')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const apiClient = axios.create({
|
||||||
|
baseURL: typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import apiClient from '@/services/api'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const token = ref<string | null>(null)
|
||||||
|
const user = ref<any>(null)
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
|
const login = async (username: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/api/v1/auth/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
|
||||||
|
token.value = response.data.token
|
||||||
|
user.value = {
|
||||||
|
id: response.data.id,
|
||||||
|
username: response.data.username,
|
||||||
|
email: response.data.email,
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('token', token.value)
|
||||||
|
localStorage.setItem('user', JSON.stringify(user.value))
|
||||||
|
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
token.value = null
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const register = async (username: string, email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post('/api/v1/auth/register', {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
token.value = null
|
||||||
|
user.value = null
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupAuthInterceptors = () => {
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
if (token.value) {
|
||||||
|
config.headers.Authorization = `Bearer ${token.value}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadSavedAuth = () => {
|
||||||
|
const savedToken = localStorage.getItem('token')
|
||||||
|
const savedUser = localStorage.getItem('user')
|
||||||
|
|
||||||
|
if (savedToken) {
|
||||||
|
token.value = savedToken
|
||||||
|
}
|
||||||
|
if (savedUser) {
|
||||||
|
user.value = JSON.parse(savedUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
setupAuthInterceptors,
|
||||||
|
loadSavedAuth,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
persist: {
|
||||||
|
enabled: true,
|
||||||
|
strategies: [
|
||||||
|
{
|
||||||
|
key: 'auth',
|
||||||
|
storage: localStorage,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* API Response types
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data?: T
|
||||||
|
error?: string
|
||||||
|
message?: string
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
token: string
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: false,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: resolve(__dirname, '../backend/src/main/resources/static'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false,
|
||||||
|
minify: 'terser',
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<!-- Vitest Configuration UI -->
|
||||||
|
export {}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Gradle Configuration
|
||||||
|
org.gradle.jvmargs=-Xmx2g
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
|
||||||
|
# Project Versions - All compatible with JDK 21
|
||||||
|
javaVersion=21
|
||||||
|
springBootVersion=4.0.+
|
||||||
|
springDependencyManagementVersion=1.1.6
|
||||||
|
springCloudVersion=2024.0.0
|
||||||
|
springSecurityVersion=6.3.1
|
||||||
|
springdocOpenApiVersion=2.5.0
|
||||||
|
openApiGeneratorVersion=7.6.0
|
||||||
|
jooqVersion=3.21.0
|
||||||
|
jooqPluginVersion=9.0
|
||||||
|
postgresqlVersion=42.7.3
|
||||||
|
flywayVersion=10.8.1
|
||||||
|
logbackVersion=1.5.0
|
||||||
|
logstashLogbackVersion=8.0
|
||||||
|
auth0-jwtVersion=4.4.0
|
||||||
|
jacksonVersion=2.18.0
|
||||||
|
commonsLang3Version=3.14.0
|
||||||
|
guavaVersion=33.1.0-jre
|
||||||
|
lombokVersion=1.18.30
|
||||||
|
junitVersion=5.10.2
|
||||||
|
mockitoVersion=5.11.0
|
||||||
|
testcontainersVersion=1.20.0
|
||||||
|
h2Version=2.3.232
|
||||||
|
restAssuredVersion=5.4.0
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
nodeVersion=20.11.0
|
||||||
|
npmVersion=10.2.4
|
||||||
|
|
||||||
|
# Docker Images
|
||||||
|
postgresDockerVersion=17-alpine
|
||||||
|
alpineVersion=3.20
|
||||||
Vendored
BIN
Binary file not shown.
+1
-3
@@ -1,7 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -1,129 +1,78 @@
|
|||||||
#!/bin/sh
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright © 2015-2021 the original authors.
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: Apache-2.0
|
|
||||||
#
|
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
#
|
##
|
||||||
# Gradle start up script for POSIX generated by Gradle.
|
## Gradle start up script for UN*X
|
||||||
#
|
##
|
||||||
# Important for running:
|
|
||||||
#
|
|
||||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
|
||||||
# noncompliant, but you have some other compliant shell such as ksh or
|
|
||||||
# bash, then to run this script, type that shell name before the whole
|
|
||||||
# command line, like:
|
|
||||||
#
|
|
||||||
# ksh Gradle
|
|
||||||
#
|
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
|
||||||
# requires all of these POSIX shell features:
|
|
||||||
# * functions;
|
|
||||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
|
||||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
|
||||||
# * compound commands having a testable exit status, especially «case»;
|
|
||||||
# * various built-in commands including «command», «set», and «ulimit».
|
|
||||||
#
|
|
||||||
# Important for patching:
|
|
||||||
#
|
|
||||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
|
||||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
|
||||||
#
|
|
||||||
# The "traditional" practice of packing multiple parameters into a
|
|
||||||
# space-separated string is a well documented source of bugs and security
|
|
||||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
|
||||||
# options in "$@", and eventually passing that to Java.
|
|
||||||
#
|
|
||||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
|
||||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
|
||||||
# see the in-line comments for details.
|
|
||||||
#
|
|
||||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
|
||||||
# Darwin, MinGW, and NonStop.
|
|
||||||
#
|
|
||||||
# (3) This script is generated from the Groovy template
|
|
||||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
|
||||||
# within the Gradle project.
|
|
||||||
#
|
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
|
||||||
#
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
|
|
||||||
# Attempt to set APP_HOME
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
# Resolve links: $0 may be a link
|
# Resolve links: $0 may be a link
|
||||||
app_path=$0
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
# Need this for daisy-chained symlinks.
|
while [ -h "$PRG" ] ; do
|
||||||
while
|
ls=`ls -ld "$PRG"`
|
||||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
[ -h "$app_path" ]
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
do
|
PRG="$link"
|
||||||
ls=$( ls -ld "$app_path" )
|
else
|
||||||
link=${ls#*' -> '}
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
case $link in #(
|
fi
|
||||||
/*) app_path=$link ;; #(
|
|
||||||
*) app_path=$APP_HOME$link ;;
|
|
||||||
esac
|
|
||||||
done
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
# This is normally unused
|
APP_NAME="Gradle"
|
||||||
# shellcheck disable=SC2034
|
APP_BASE_NAME=`basename "$0"`
|
||||||
APP_BASE_NAME=${0##*/}
|
|
||||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
DEFAULT_JVM_OPTS=""
|
||||||
|
|
||||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD=maximum
|
MAX_FD="maximum"
|
||||||
|
|
||||||
warn () {
|
warn () {
|
||||||
echo "$*"
|
echo "$*"
|
||||||
} >&2
|
}
|
||||||
|
|
||||||
die () {
|
die () {
|
||||||
echo
|
echo
|
||||||
echo "$*"
|
echo "$*"
|
||||||
echo
|
echo
|
||||||
exit 1
|
exit 1
|
||||||
} >&2
|
}
|
||||||
|
|
||||||
# OS specific support (must be 'true' or 'false').
|
# OS specific support (must be 'true' or 'false').
|
||||||
cygwin=false
|
cygwin=false
|
||||||
msys=false
|
msys=false
|
||||||
darwin=false
|
darwin=false
|
||||||
nonstop=false
|
nonstop=false
|
||||||
case "$( uname )" in #(
|
case "`uname`" in
|
||||||
CYGWIN* ) cygwin=true ;; #(
|
CYGWIN* )
|
||||||
Darwin* ) darwin=true ;; #(
|
cygwin=true
|
||||||
MSYS* | MINGW* ) msys=true ;; #(
|
;;
|
||||||
NONSTOP* ) nonstop=true ;;
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH="\\\"\\\""
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
if [ -n "$JAVA_HOME" ] ; then
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
# IBM's JDK on AIX uses strange locations for the executables
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
else
|
else
|
||||||
JAVACMD=$JAVA_HOME/bin/java
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
fi
|
fi
|
||||||
if [ ! -x "$JAVACMD" ] ; then
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
@@ -132,120 +81,96 @@ Please set the JAVA_HOME variable in your environment to match the
|
|||||||
location of your Java installation."
|
location of your Java installation."
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
JAVACMD=java
|
JAVACMD="java"
|
||||||
if ! command -v java >/dev/null 2>&1
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
then
|
|
||||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
|
||||||
|
|
||||||
Please set the JAVA_HOME variable in your environment to match the
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
location of your Java installation."
|
location of your Java installation."
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Increase the maximum file descriptors if we can.
|
# Increase the maximum file descriptors if we can.
|
||||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
case $MAX_FD in #(
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
max*)
|
if [ $? -eq 0 ] ; then
|
||||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
# shellcheck disable=SC2039,SC3045
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
|
||||||
warn "Could not query maximum file descriptor limit"
|
|
||||||
esac
|
|
||||||
case $MAX_FD in #(
|
|
||||||
'' | soft) :;; #(
|
|
||||||
*)
|
|
||||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
|
||||||
# shellcheck disable=SC2039,SC3045
|
|
||||||
ulimit -n "$MAX_FD" ||
|
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
|
||||||
esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Collect all arguments for the java command, stacking in reverse order:
|
|
||||||
# * args from the command line
|
|
||||||
# * the main class name
|
|
||||||
# * -classpath
|
|
||||||
# * -D...appname settings
|
|
||||||
# * --module-path (only if needed)
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
|
||||||
|
|
||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
|
||||||
if "$cygwin" || "$msys" ; then
|
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
|
||||||
|
|
||||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
|
||||||
for arg do
|
|
||||||
if
|
|
||||||
case $arg in #(
|
|
||||||
-*) false ;; # don't mess with options #(
|
|
||||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
|
||||||
[ -e "$t" ] ;; #(
|
|
||||||
*) false ;;
|
|
||||||
esac
|
|
||||||
then
|
|
||||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
|
||||||
fi
|
fi
|
||||||
# Roll the args list around exactly as many times as the number of
|
ulimit -n $MAX_FD
|
||||||
# args, so each arg winds up back in the position where it started, but
|
if [ $? -ne 0 ] ; then
|
||||||
# possibly modified.
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
#
|
fi
|
||||||
# NB: a `for` loop captures its iteration list before it begins, so
|
else
|
||||||
# changing the positional parameters here affects neither the number of
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
# iterations, nor the values presented in `arg`.
|
fi
|
||||||
shift # remove old arg
|
fi
|
||||||
set -- "$@" "$arg" # push replacement arg
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin, switch paths to Windows format before running java
|
||||||
|
if $cygwin ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
done
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
(0) set -- ;;
|
||||||
|
(1) set -- "$args0" ;;
|
||||||
|
(2) set -- "$args0" "$args1" ;;
|
||||||
|
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=$(save "$@")
|
||||||
|
|
||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
|
||||||
|
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
|
||||||
# Collect all arguments for the java command:
|
|
||||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
|
||||||
# and any embedded shellness will be escaped.
|
|
||||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
|
||||||
# treated as '${Hostname}' itself on the command line.
|
|
||||||
|
|
||||||
set -- \
|
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
|
||||||
-classpath "$CLASSPATH" \
|
|
||||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
|
||||||
"$@"
|
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
|
||||||
if ! command -v xargs >/dev/null 2>&1
|
|
||||||
then
|
|
||||||
die "xargs is not available"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Use "xargs" to parse quoted args.
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
#
|
|
||||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
|
||||||
#
|
|
||||||
# In Bash we could simply go:
|
|
||||||
#
|
|
||||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
|
||||||
# set -- "${ARGS[@]}" "$@"
|
|
||||||
#
|
|
||||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
|
||||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
|
||||||
# character that might be a shell metacharacter, then use eval to reverse
|
|
||||||
# that process (while maintaining the separation between arguments), and wrap
|
|
||||||
# the whole thing up as a single "set" statement.
|
|
||||||
#
|
|
||||||
# This will of course break if any of these variables contains a newline or
|
|
||||||
# an unmatched quote.
|
|
||||||
#
|
|
||||||
|
|
||||||
eval "set -- $(
|
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||||
xargs -n1 |
|
cd "$(dirname "$0")"
|
||||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
fi
|
||||||
tr '\n' ' '
|
|
||||||
)" '"$@"'
|
|
||||||
|
|
||||||
exec "$JAVACMD" "$@"
|
exec "$JAVACMD" "$@"
|
||||||
|
|||||||
Vendored
+84
-94
@@ -1,94 +1,84 @@
|
|||||||
@rem
|
@if "%DEBUG%" == "" @echo off
|
||||||
@rem Copyright 2015 the original author or authors.
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
@rem Gradle startup script for Windows
|
||||||
@rem you may not use this file except in compliance with the License.
|
@rem
|
||||||
@rem You may obtain a copy of the License at
|
@rem ##########################################################################
|
||||||
@rem
|
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
@rem Set local scope for the variables with windows NT shell
|
||||||
@rem
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
|
||||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
set DIRNAME=%~dp0
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
@rem See the License for the specific language governing permissions and
|
set APP_BASE_NAME=%~n0
|
||||||
@rem limitations under the License.
|
set APP_HOME=%DIRNAME%
|
||||||
@rem
|
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
@rem
|
set DEFAULT_JVM_OPTS=
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
@rem Find java.exe
|
||||||
@rem ##########################################################################
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
@rem
|
|
||||||
@rem Gradle startup script for Windows
|
set JAVA_EXE=java.exe
|
||||||
@rem
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
@rem ##########################################################################
|
if "%ERRORLEVEL%" == "0" goto init
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
echo.
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
set DIRNAME=%~dp0
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
echo location of your Java installation.
|
||||||
@rem This is normally unused
|
|
||||||
set APP_BASE_NAME=%~n0
|
goto fail
|
||||||
set APP_HOME=%DIRNAME%
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
if exist "%JAVA_EXE%" goto init
|
||||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
|
||||||
|
echo.
|
||||||
@rem Find java.exe
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
set JAVA_EXE=java.exe
|
echo location of your Java installation.
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
goto fail
|
||||||
|
|
||||||
echo. 1>&2
|
:init
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
@rem Get command-line arguments, handling Windows variants
|
||||||
echo. 1>&2
|
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||||
echo location of your Java installation. 1>&2
|
|
||||||
|
:win9xME_args
|
||||||
goto fail
|
@rem Slurp the command line arguments.
|
||||||
|
set CMD_LINE_ARGS=
|
||||||
:findJavaFromJavaHome
|
set _SKIP=2
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
:win9xME_args_slurp
|
||||||
|
if "x%~1" == "x" goto execute
|
||||||
if exist "%JAVA_EXE%" goto execute
|
|
||||||
|
set CMD_LINE_ARGS=%*
|
||||||
echo. 1>&2
|
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
:execute
|
||||||
echo. 1>&2
|
@rem Setup the command line
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
|
||||||
echo location of your Java installation. 1>&2
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
goto fail
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||||
:execute
|
|
||||||
@rem Setup the command line
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
set CLASSPATH=
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
@rem Execute Gradle
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
:end
|
exit /b 1
|
||||||
@rem End local scope for the variables with windows NT shell
|
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
:fail
|
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
:omega
|
||||||
rem the _cmd.exe /c_ return code!
|
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
|
||||||
exit /b %EXIT_CODE%
|
|
||||||
|
|
||||||
:mainEnd
|
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
|
||||||
|
|
||||||
:omega
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
repositories {
|
|
||||||
maven{ url 'https://mvnrepository.com/'}
|
|
||||||
|
|
||||||
gradlePluginPortal()
|
|
||||||
// If you have other custom repositories, add them here
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootProject.name = 'demo'
|
|
||||||
|
|
||||||
|
|
||||||
include 'db'
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
rootProject.name = "mtg-search"
|
||||||
|
|
||||||
|
include(":backend")
|
||||||
|
include(":frontend")
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<!--
|
|
||||||
This file is auto-generated by Vaadin.
|
|
||||||
-->
|
|
||||||
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
||||||
<style>
|
|
||||||
html, body, #outlet {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- This outlet div is where the views are rendered -->
|
|
||||||
<div id="outlet"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package com.example.demo;
|
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|
||||||
|
|
||||||
@SpringBootApplication
|
|
||||||
public class DemoApplication {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
SpringApplication.run(DemoApplication.class, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
vaadin.launch-browser=true
|
|
||||||
spring.application.name=demo
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package com.example.demo;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
|
||||||
|
|
||||||
@SpringBootTest
|
|
||||||
class DemoApplicationTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void contextLoads() {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
+185
@@ -0,0 +1,185 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Startup script for MTG Search Project
|
||||||
|
# This script helps set up and run the project
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${BLUE}=== MTG Search Project Startup ===${NC}"
|
||||||
|
|
||||||
|
# Check prerequisites
|
||||||
|
check_prerequisites() {
|
||||||
|
echo -e "\n${BLUE}Checking prerequisites...${NC}"
|
||||||
|
|
||||||
|
# Check Java
|
||||||
|
if ! command -v java &> /dev/null; then
|
||||||
|
echo -e "${RED}✗ Java not found. Please install Java 21+${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
JAVA_VERSION=$(java -version 2>&1 | grep -oP 'version "\K[0-9.]+')
|
||||||
|
echo -e "${GREEN}✓ Java $JAVA_VERSION found${NC}"
|
||||||
|
|
||||||
|
# Check Node.js
|
||||||
|
if ! command -v node &> /dev/null; then
|
||||||
|
echo -e "${RED}✗ Node.js not found. Please install Node.js 20+${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
NODE_VERSION=$(node -v)
|
||||||
|
echo -e "${GREEN}✓ Node.js $NODE_VERSION found${NC}"
|
||||||
|
|
||||||
|
# Check PostgreSQL
|
||||||
|
if ! command -v psql &> /dev/null; then
|
||||||
|
if ! command -v docker &> /dev/null; then
|
||||||
|
echo -e "${RED}✗ PostgreSQL and Docker not found. Please install one of them${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}✓ Docker found (will use for PostgreSQL)${NC}"
|
||||||
|
else
|
||||||
|
PG_VERSION=$(psql --version)
|
||||||
|
echo -e "${GREEN}✓ $PG_VERSION found${NC}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Setup database
|
||||||
|
setup_database() {
|
||||||
|
echo -e "\n${BLUE}Setting up database...${NC}"
|
||||||
|
|
||||||
|
# Check if PostgreSQL is running
|
||||||
|
if ! psql -U postgres -h localhost -d postgres -c "SELECT 1" &> /dev/null; then
|
||||||
|
echo -e "${BLUE}Starting PostgreSQL with Docker...${NC}"
|
||||||
|
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -q mtgsearch-postgres; then
|
||||||
|
docker start mtgsearch-postgres
|
||||||
|
echo -e "${GREEN}✓ PostgreSQL container started${NC}"
|
||||||
|
else
|
||||||
|
docker run --name mtgsearch-postgres \
|
||||||
|
-e POSTGRES_DB=mtgsearch \
|
||||||
|
-e POSTGRES_USER=postgres \
|
||||||
|
-e POSTGRES_PASSWORD=postgres \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-d postgres:17-alpine
|
||||||
|
|
||||||
|
echo -e "${BLUE}Waiting for PostgreSQL to be ready...${NC}"
|
||||||
|
sleep 10
|
||||||
|
echo -e "${GREEN}✓ PostgreSQL container created and started${NC}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo -e "${GREEN}✓ PostgreSQL is already running${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create database if it doesn't exist
|
||||||
|
psql -U postgres -h localhost -c "CREATE DATABASE mtgsearch;" 2>/dev/null || true
|
||||||
|
echo -e "${GREEN}✓ Database ready${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build backend
|
||||||
|
build_backend() {
|
||||||
|
echo -e "\n${BLUE}Building backend...${NC}"
|
||||||
|
cd backend
|
||||||
|
../gradlew clean build -x test
|
||||||
|
cd ..
|
||||||
|
echo -e "${GREEN}✓ Backend built successfully${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Install frontend dependencies
|
||||||
|
install_frontend() {
|
||||||
|
echo -e "\n${BLUE}Installing frontend dependencies...${NC}"
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
cd ..
|
||||||
|
echo -e "${GREEN}✓ Frontend dependencies installed${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
build_frontend() {
|
||||||
|
echo -e "\n${BLUE}Building frontend...${NC}"
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
cd ..
|
||||||
|
echo -e "${GREEN}✓ Frontend built successfully${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display startup options
|
||||||
|
show_menu() {
|
||||||
|
echo -e "\n${BLUE}=== Startup Options ===${NC}"
|
||||||
|
echo "1. Full Setup (database + build everything)"
|
||||||
|
echo "2. Setup Database Only"
|
||||||
|
echo "3. Build Backend Only"
|
||||||
|
echo "4. Build Frontend Only"
|
||||||
|
echo "5. Run Backend (requires database)"
|
||||||
|
echo "6. Run Frontend Dev Server"
|
||||||
|
echo "7. Run Both (Backend + Frontend)"
|
||||||
|
echo "8. Docker Compose (recommended)"
|
||||||
|
echo "9. Exit"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main menu
|
||||||
|
main_menu() {
|
||||||
|
while true; do
|
||||||
|
show_menu
|
||||||
|
read -p "Select option (1-9): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
check_prerequisites
|
||||||
|
setup_database
|
||||||
|
build_backend
|
||||||
|
install_frontend
|
||||||
|
build_frontend
|
||||||
|
echo -e "\n${GREEN}✓ Setup complete!${NC}"
|
||||||
|
echo -e "${BLUE}Run 'make run' to start the backend${NC}"
|
||||||
|
echo -e "${BLUE}In another terminal, run 'cd frontend && npm run dev' for frontend${NC}"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
check_prerequisites
|
||||||
|
setup_database
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
build_backend
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
install_frontend
|
||||||
|
build_frontend
|
||||||
|
;;
|
||||||
|
5)
|
||||||
|
check_prerequisites
|
||||||
|
echo -e "\n${BLUE}Starting backend on http://localhost:8080${NC}"
|
||||||
|
cd backend
|
||||||
|
../gradlew bootRun
|
||||||
|
;;
|
||||||
|
6)
|
||||||
|
echo -e "\n${BLUE}Starting frontend dev server on http://localhost:5173${NC}"
|
||||||
|
cd frontend
|
||||||
|
npm run dev
|
||||||
|
;;
|
||||||
|
7)
|
||||||
|
echo -e "\n${BLUE}Starting Backend + Frontend${NC}"
|
||||||
|
echo -e "${BLUE}Frontend: http://localhost:5173${NC}"
|
||||||
|
echo -e "${BLUE}Backend: http://localhost:8080${NC}"
|
||||||
|
(cd backend && ../gradlew bootRun) &
|
||||||
|
(cd frontend && npm run dev) &
|
||||||
|
wait
|
||||||
|
;;
|
||||||
|
8)
|
||||||
|
echo -e "\n${BLUE}Starting with Docker Compose${NC}"
|
||||||
|
docker-compose up --build
|
||||||
|
;;
|
||||||
|
9)
|
||||||
|
echo -e "${GREEN}Goodbye!${NC}"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Invalid option. Please try again.${NC}"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main menu
|
||||||
|
main_menu
|
||||||
Reference in New Issue
Block a user