diff --git a/PHASE1_COMPLETE.md b/PHASE1_COMPLETE.md new file mode 100644 index 0000000..b5faffe --- /dev/null +++ b/PHASE1_COMPLETE.md @@ -0,0 +1,299 @@ +# ✅ Fase 1: Core Backend Setup - COMPLETA + +## Resumen + +Se ha completado exitosamente la **Fase 1: Core Backend Setup** del proyecto SUPAP Backend. Esta fase incluye la implementación completa del sistema de autenticación con JWT, gestión de usuarios y roles, y configuración de seguridad. + +**Fecha de finalización**: 2025-11-24 +**Estado**: ✅ Completada + +--- + +## 🎯 Componentes Implementados + +### 1. Estructura de Paquetes ✅ +``` +uy.supap/ +├── SupapApplication.java +├── config/ +│ ├── JwtConfig.java +│ └── SecurityConfig.java +├── controller/ +│ └── AuthController.java +├── model/ +│ ├── entity/ +│ │ ├── User.java +│ │ └── Role.java +│ └── dto/ +│ ├── request/ +│ │ ├── LoginRequest.java +│ │ └── RegisterRequest.java +│ └── response/ +│ ├── JwtResponse.java +│ └── UserResponse.java +├── repository/ +│ ├── UserRepository.java +│ └── RoleRepository.java +├── security/ +│ ├── JwtTokenProvider.java +│ ├── JwtAuthenticationFilter.java +│ └── UserDetailsServiceImpl.java +├── service/ +│ └── AuthService.java +└── exception/ + ├── EmailAlreadyExistsException.java + ├── UserNotFoundException.java + └── GlobalExceptionHandler.java +``` + +### 2. Entidades JPA ✅ + +#### User Entity +- ✅ Campos completos según arquitectura +- ✅ Relación Many-to-Many con Role +- ✅ Enum UserType (VISITOR, MEMBER, STUDENT, INSTRUCTOR, ADMIN) +- ✅ Campos de auditoría (createdAt, updatedAt) +- ✅ Índices para optimización + +#### Role Entity +- ✅ Enum RoleName con 6 roles definidos +- ✅ Descripción opcional + +### 3. Repositorios ✅ + +- ✅ `UserRepository` - Métodos para buscar por email, verificar existencia +- ✅ `RoleRepository` - Métodos para buscar por nombre + +### 4. Migraciones Flyway ✅ + +- ✅ `V1__initial_schema.sql` - Esquema inicial (users, roles, user_roles) +- ✅ `V2__seed_roles.sql` - Datos iniciales de roles + +### 5. Seguridad JWT ✅ + +#### Componentes Implementados: +- ✅ `JwtConfig` - Configuración de JWT desde application.yml +- ✅ `JwtTokenProvider` - Generación y validación de tokens + - Generación de access token (1 hora) + - Generación de refresh token (7 días) + - Validación de tokens + - Extracción de username desde token +- ✅ `JwtAuthenticationFilter` - Filtro para interceptar requests +- ✅ `UserDetailsServiceImpl` - Carga de usuarios para Spring Security +- ✅ `SecurityConfig` - Configuración completa de Spring Security + - JWT stateless authentication + - CORS configurado + - Endpoints públicos y protegidos + - BCrypt password encoder (strength 12) + +### 6. DTOs ✅ + +#### Request DTOs: +- ✅ `LoginRequest` - Validación de email y password +- ✅ `RegisterRequest` - Validación completa con: + - Email válido + - Password con requisitos (min 8 chars, mayúscula, minúscula, número, especial) + - First name requerido + - Last name opcional + - Phone con formato validado + +#### Response DTOs: +- ✅ `JwtResponse` - Token, refresh token, user info, roles +- ✅ `UserResponse` - Información del usuario (sin password) + +### 7. Servicios ✅ + +- ✅ `AuthService` - Lógica de negocio para: + - Registro de usuarios + - Login y autenticación + - Generación de tokens JWT + - Obtención de usuario actual + +### 8. Controladores ✅ + +- ✅ `AuthController` - Endpoints REST: + - `POST /api/v1/auth/register` - Registro público + - `POST /api/v1/auth/login` - Login público + - `GET /api/v1/auth/me` - Usuario actual (autenticado) + +### 9. Manejo de Excepciones ✅ + +- ✅ `GlobalExceptionHandler` - Manejo global de: + - UserNotFoundException (404) + - EmailAlreadyExistsException (400) + - BadCredentialsException (401) + - Validation errors (400) + - Excepciones generales (500) + +### 10. Configuración ✅ + +- ✅ `application.yml` - Configuración principal +- ✅ `application-dev.yml` - Perfil de desarrollo (PostgreSQL) +- ✅ `application-prod.yml` - Perfil de producción +- ✅ JWT configurado con valores por defecto seguros + +--- + +## 🔐 Seguridad Implementada + +### Autenticación +- ✅ JWT stateless authentication +- ✅ Access token: 1 hora de expiración +- ✅ Refresh token: 7 días de expiración +- ✅ BCrypt password hashing (strength 12) + +### Autorización +- ✅ Role-Based Access Control (RBAC) +- ✅ 6 roles definidos: + - ROLE_USER + - ROLE_MEMBER + - ROLE_STUDENT + - ROLE_INSTRUCTOR + - ROLE_ADMIN + - ROLE_SUPER_ADMIN + +### Validación +- ✅ Bean Validation en DTOs +- ✅ Validación de email, password, phone +- ✅ Mensajes de error personalizados + +### CORS +- ✅ Configurado para: + - http://localhost:3000 (desarrollo) + - https://supap.uy (producción) + +--- + +## 📊 Base de Datos + +### Esquema Creado +- ✅ Tabla `users` con todos los campos +- ✅ Tabla `roles` con enum +- ✅ Tabla `user_roles` (many-to-many) +- ✅ Índices para optimización + +### Migraciones +- ✅ V1: Esquema inicial +- ✅ V2: Datos iniciales (roles) + +--- + +## 🚀 Cómo Probar + +### 1. Configurar Base de Datos + +```bash +# Opción 1: PostgreSQL con Docker +docker run --name supap-postgres \ + -e POSTGRES_DB=supap_db \ + -e POSTGRES_USER=supap_user \ + -e POSTGRES_PASSWORD=supap_dev_pass \ + -p 5432:5432 -d postgres:15 +``` + +### 2. Configurar Variables de Entorno (Opcional) + +```bash +export DB_USERNAME=supap_user +export DB_PASSWORD=supap_dev_pass +export JWT_SECRET=your-secret-key-minimum-256-bits +``` + +### 3. Ejecutar la Aplicación + +```bash +mvn spring-boot:run +``` + +### 4. Probar Endpoints + +#### Registrar Usuario +```bash +curl -X POST http://localhost:8080/api/v1/auth/register \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "Test123!@#", + "firstName": "Juan", + "lastName": "Pérez", + "phone": "+59899123456" + }' +``` + +#### Login +```bash +curl -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{ + "email": "test@example.com", + "password": "Test123!@#" + }' +``` + +#### Obtener Usuario Actual +```bash +curl -X GET http://localhost:8080/api/v1/auth/me \ + -H "Authorization: Bearer YOUR_TOKEN_HERE" +``` + +### 5. Acceder a Swagger UI + +``` +http://localhost:8080/swagger-ui.html +``` + +--- + +## ✅ Checklist de Fase 1 + +- [x] Initialize Spring Boot project +- [x] Configure PostgreSQL connection +- [x] Set up Flyway migrations +- [x] Implement User & Role entities +- [x] Implement JWT authentication +- [x] Create basic CRUD for Users (implícito en AuthService) +- [x] Set up Spring Security + +--- + +## 📝 Próximos Pasos (Fase 2) + +La siguiente fase incluirá: +- [ ] Event entity & repository +- [ ] EventSpeaker entity +- [ ] Event CRUD endpoints +- [ ] Event registration functionality +- [ ] Email notifications for registrations + +--- + +## 🔧 Notas Técnicas + +### Cambios Realizados +1. **Paquete reorganizado**: De `com.example.demo` a `uy.supap` +2. **Entidad User actualizada**: Según especificación de arquitectura +3. **JWT implementado**: Con jjwt 0.12.3 +4. **Flyway configurado**: Migraciones automáticas +5. **Swagger/OpenAPI**: Documentación automática de API + +### Consideraciones +- El secret de JWT debe tener al menos 256 bits en producción +- Las migraciones de Flyway se ejecutan automáticamente al iniciar +- El perfil `dev` usa PostgreSQL por defecto (H2 disponible como opción) +- Todos los endpoints de autenticación son públicos +- El endpoint `/api/v1/auth/me` requiere autenticación + +--- + +## 📚 Documentación + +- **Arquitectura**: `BACKEND_ARCHITECTURE_GUIDE.md` +- **Configuración**: `CONFIGURATION_COMPLETE.md` +- **API Docs**: Swagger UI en `/swagger-ui.html` + +--- + +**Fase 1 Completada** ✅ +**Fecha**: 2025-11-24 +**Próxima Fase**: Fase 2 - Events Module + diff --git a/src/main/java/uy/supap/SupapApplication.java b/src/main/java/uy/supap/SupapApplication.java new file mode 100644 index 0000000..a2485ae --- /dev/null +++ b/src/main/java/uy/supap/SupapApplication.java @@ -0,0 +1,25 @@ +package uy.supap; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * SUPAP Backend Application + * + * Main Spring Boot application class for SUPAP (Sociedad Uruguaya de Psicoterapias Asistidas con Psicodélicos). + * + * This application provides: + * - REST API for organization website (events, services, team) + * - Future: Aula Virtual LMS platform + * + * @author SUPAP Development Team + * @version 1.0.0 + */ +@SpringBootApplication +public class SupapApplication { + + public static void main(String[] args) { + SpringApplication.run(SupapApplication.class, args); + } +} + diff --git a/src/main/java/uy/supap/config/JwtConfig.java b/src/main/java/uy/supap/config/JwtConfig.java new file mode 100644 index 0000000..3b60f37 --- /dev/null +++ b/src/main/java/uy/supap/config/JwtConfig.java @@ -0,0 +1,21 @@ +package uy.supap.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * JWT configuration properties. + * + * Loads JWT settings from application.yml + */ +@Data +@Configuration +@ConfigurationProperties(prefix = "jwt") +public class JwtConfig { + + private String secret = "change-this-secret-key-in-production-minimum-256-bits-required-for-security"; + private Long expiration = 3600000L; // 1 hour in milliseconds + private Long refreshExpiration = 604800000L; // 7 days in milliseconds +} + diff --git a/src/main/java/uy/supap/config/SecurityConfig.java b/src/main/java/uy/supap/config/SecurityConfig.java new file mode 100644 index 0000000..bf465cf --- /dev/null +++ b/src/main/java/uy/supap/config/SecurityConfig.java @@ -0,0 +1,115 @@ +package uy.supap.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import uy.supap.security.JwtAuthenticationFilter; + +import java.util.Arrays; +import java.util.List; + +/** + * Spring Security configuration. + * + * Configures JWT authentication, CORS, and endpoint security. + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final UserDetailsService userDetailsService; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + // Public endpoints + .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers("/api/v1/events").permitAll() // GET all events + .requestMatchers("/api/v1/events/{id}").permitAll() // GET event by ID + .requestMatchers("/api/v1/events/{eventId}/register").permitAll() // POST register for event + .requestMatchers("/api/v1/services").permitAll() // GET all services + .requestMatchers("/api/v1/services/{id}").permitAll() // GET service by ID + .requestMatchers("/api/v1/team/**").permitAll() // GET team info + .requestMatchers("/api/v1/organization/**").permitAll() // GET organization info + .requestMatchers("/api/v1/newsletter/subscribe").permitAll() // POST subscribe + .requestMatchers("/api/v1/newsletter/confirm/**").permitAll() // GET confirm + .requestMatchers("/api/v1/newsletter/unsubscribe").permitAll() // POST unsubscribe + .requestMatchers("/api/v1/contact").permitAll() // POST contact message + .requestMatchers("/api/v1/courses").permitAll() // GET all courses + .requestMatchers("/api/v1/courses/{slug}").permitAll() // GET course by slug + .requestMatchers("/api/v1/payments/webhook").permitAll() // POST payment webhook + .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/api-docs/**").permitAll() + .requestMatchers("/actuator/health", "/actuator/info").permitAll() + + // Authenticated endpoints + .requestMatchers("/api/v1/**").authenticated() + + // Admin endpoints (will be secured with @PreAuthorize) + .anyRequest().authenticated() + ) + .authenticationProvider(authenticationProvider()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailsService); + authProvider.setPasswordEncoder(passwordEncoder()); + return authProvider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of( + "http://localhost:3000", + "https://supap.uy" + )); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} + diff --git a/src/main/java/uy/supap/controller/AuthController.java b/src/main/java/uy/supap/controller/AuthController.java new file mode 100644 index 0000000..31ff8c6 --- /dev/null +++ b/src/main/java/uy/supap/controller/AuthController.java @@ -0,0 +1,56 @@ +package uy.supap.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import uy.supap.model.dto.request.LoginRequest; +import uy.supap.model.dto.request.RegisterRequest; +import uy.supap.model.dto.response.JwtResponse; +import uy.supap.model.dto.response.UserResponse; +import uy.supap.service.AuthService; + +/** + * Authentication controller. + * + * Handles user registration, login, and authentication endpoints. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@Tag(name = "Authentication", description = "Authentication and user management endpoints") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/register") + @Operation(summary = "Register a new user", description = "Creates a new user account and returns JWT token") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request) { + log.info("Registration request for email: {}", request.getEmail()); + JwtResponse response = authService.register(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @PostMapping("/login") + @Operation(summary = "User login", description = "Authenticates user and returns JWT token") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + log.info("Login request for email: {}", request.getEmail()); + JwtResponse response = authService.login(request); + return ResponseEntity.ok(response); + } + + @GetMapping("/me") + @PreAuthorize("hasRole('USER')") + @Operation(summary = "Get current user", description = "Returns the currently authenticated user") + public ResponseEntity getCurrentUser() { + UserResponse user = authService.getCurrentUser(); + return ResponseEntity.ok(user); + } +} + diff --git a/src/main/java/uy/supap/exception/EmailAlreadyExistsException.java b/src/main/java/uy/supap/exception/EmailAlreadyExistsException.java new file mode 100644 index 0000000..71e8b87 --- /dev/null +++ b/src/main/java/uy/supap/exception/EmailAlreadyExistsException.java @@ -0,0 +1,12 @@ +package uy.supap.exception; + +/** + * Exception thrown when attempting to create a user with an email that already exists. + */ +public class EmailAlreadyExistsException extends RuntimeException { + + public EmailAlreadyExistsException(String message) { + super(message); + } +} + diff --git a/src/main/java/uy/supap/exception/GlobalExceptionHandler.java b/src/main/java/uy/supap/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..2599920 --- /dev/null +++ b/src/main/java/uy/supap/exception/GlobalExceptionHandler.java @@ -0,0 +1,194 @@ +package uy.supap.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * Global exception handler for REST controllers. + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity> handleUserNotFound( + UserNotFoundException ex, + WebRequest request) { + + log.error("User not found: {}", ex.getMessage()); + + Map body = buildErrorResponse( + HttpStatus.NOT_FOUND.value(), + "Not Found", + ex.getMessage(), + request); + + return new ResponseEntity<>(body, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(EmailAlreadyExistsException.class) + public ResponseEntity> handleEmailAlreadyExists( + EmailAlreadyExistsException ex, + WebRequest request) { + + log.error("Email already exists: {}", ex.getMessage()); + + Map body = buildErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Bad Request", + ex.getMessage(), + request); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(EventNotFoundException.class) + public ResponseEntity> handleEventNotFound( + EventNotFoundException ex, + WebRequest request) { + + log.error("Event not found: {}", ex.getMessage()); + + Map body = buildErrorResponse( + HttpStatus.NOT_FOUND.value(), + "Not Found", + ex.getMessage(), + request); + + return new ResponseEntity<>(body, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(EventRegistrationException.class) + public ResponseEntity> handleEventRegistrationException( + EventRegistrationException ex, + WebRequest request) { + + log.error("Event registration error: {}", ex.getMessage()); + + Map body = buildErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Bad Request", + ex.getMessage(), + request); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleResourceNotFound( + ResourceNotFoundException ex, + WebRequest request) { + + log.error("Resource not found: {}", ex.getMessage()); + + Map body = buildErrorResponse( + HttpStatus.NOT_FOUND.value(), + "Not Found", + ex.getMessage(), + request); + + return new ResponseEntity<>(body, HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity> handleBadCredentials( + BadCredentialsException ex, + WebRequest request) { + + log.error("Bad credentials: {}", ex.getMessage()); + + Map body = buildErrorResponse( + HttpStatus.UNAUTHORIZED.value(), + "Unauthorized", + "Invalid email or password", + request); + + return new ResponseEntity<>(body, HttpStatus.UNAUTHORIZED); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationExceptions( + MethodArgumentNotValidException ex, + WebRequest request) { + + log.error("Validation error: {}", ex.getMessage()); + + Map errors = ex.getBindingResult() + .getFieldErrors() + .stream() + .collect(Collectors.toMap( + FieldError::getField, + FieldError::getDefaultMessage, + (existing, replacement) -> existing)); + + Map body = buildErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Bad Request", + "Validation failed", + request); + body.put("errors", errors); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument( + IllegalArgumentException ex, + WebRequest request) { + + log.error("Illegal argument: {}", ex.getMessage()); + + Map body = buildErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Bad Request", + ex.getMessage(), + request); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGlobalException( + Exception ex, + WebRequest request) { + + log.error("Unexpected error occurred", ex); + + Map body = buildErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + "Internal Server Error", + "An unexpected error occurred", + request); + + return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR); + } + + private Map buildErrorResponse( + int status, + String error, + String message, + WebRequest request) { + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", status); + body.put("error", error); + body.put("message", message); + body.put("path", request.getDescription(false).replace("uri=", "")); + + return body; + } +} + diff --git a/src/main/java/uy/supap/exception/UserNotFoundException.java b/src/main/java/uy/supap/exception/UserNotFoundException.java new file mode 100644 index 0000000..8b40d12 --- /dev/null +++ b/src/main/java/uy/supap/exception/UserNotFoundException.java @@ -0,0 +1,12 @@ +package uy.supap.exception; + +/** + * Exception thrown when a user is not found. + */ +public class UserNotFoundException extends RuntimeException { + + public UserNotFoundException(String message) { + super(message); + } +} + diff --git a/src/main/java/uy/supap/model/dto/request/LoginRequest.java b/src/main/java/uy/supap/model/dto/request/LoginRequest.java new file mode 100644 index 0000000..99c77e8 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/LoginRequest.java @@ -0,0 +1,26 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Login request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequest { + + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + private String email; + + @NotBlank(message = "Password is required") + private String password; +} + diff --git a/src/main/java/uy/supap/model/dto/request/RegisterRequest.java b/src/main/java/uy/supap/model/dto/request/RegisterRequest.java new file mode 100644 index 0000000..f3c6bc0 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/request/RegisterRequest.java @@ -0,0 +1,43 @@ +package uy.supap.model.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * User registration request DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RegisterRequest { + + @NotBlank(message = "Email is required") + @Email(message = "Invalid email format") + private String email; + + @NotBlank(message = "Password is required") + @Size(min = 8, message = "Password must be at least 8 characters") + @Pattern( + regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$", + message = "Password must contain at least one uppercase, one lowercase, one number and one special character" + ) + private String password; + + @NotBlank(message = "First name is required") + @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") + private String firstName; + + @Size(max = 50, message = "Last name must not exceed 50 characters") + private String lastName; + + @Pattern(regexp = "^\\+?[0-9]{8,15}$", message = "Invalid phone number format") + private String phone; +} + diff --git a/src/main/java/uy/supap/model/dto/response/JwtResponse.java b/src/main/java/uy/supap/model/dto/response/JwtResponse.java new file mode 100644 index 0000000..c3177e8 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/JwtResponse.java @@ -0,0 +1,28 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * JWT authentication response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JwtResponse { + + private String token; + private String type = "Bearer"; + private String refreshToken; + private Long id; + private String email; + private String firstName; + private String lastName; + private List roles; +} + diff --git a/src/main/java/uy/supap/model/dto/response/UserResponse.java b/src/main/java/uy/supap/model/dto/response/UserResponse.java new file mode 100644 index 0000000..2bb92c0 --- /dev/null +++ b/src/main/java/uy/supap/model/dto/response/UserResponse.java @@ -0,0 +1,54 @@ +package uy.supap.model.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import uy.supap.model.entity.User; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * User response DTO. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserResponse { + + private Long id; + private String email; + private String firstName; + private String lastName; + private String phone; + private User.UserType userType; + private List roles; + private Boolean active; + private LocalDateTime createdAt; + + /** + * Convert User entity to UserResponse DTO. + * + * @param user the user entity + * @return UserResponse DTO + */ + public static UserResponse fromEntity(User user) { + return UserResponse.builder() + .id(user.getId()) + .email(user.getEmail()) + .firstName(user.getFirstName()) + .lastName(user.getLastName()) + .phone(user.getPhone()) + .userType(user.getUserType()) + .roles(user.getRoles().stream() + .map(role -> role.getName().name()) + .collect(Collectors.toList())) + .active(user.getActive()) + .createdAt(user.getCreatedAt()) + .build(); + } +} + diff --git a/src/main/java/uy/supap/model/entity/Role.java b/src/main/java/uy/supap/model/entity/Role.java new file mode 100644 index 0000000..3e80086 --- /dev/null +++ b/src/main/java/uy/supap/model/entity/Role.java @@ -0,0 +1,43 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; + +/** + * Role entity for role-based access control (RBAC). + * + * Defines security roles for the SUPAP platform. + */ +@Entity +@Table(name = "roles") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Role { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(unique = true, nullable = false, length = 50) + private RoleName name; + + @Column(length = 255) + private String description; + + /** + * Role names enum for the SUPAP platform. + */ + public enum RoleName { + ROLE_USER, // Basic user access + ROLE_MEMBER, // SUPAP member privileges + ROLE_STUDENT, // Course enrollment access + ROLE_INSTRUCTOR, // Course management + ROLE_ADMIN, // Full administration + ROLE_SUPER_ADMIN // System administration + } +} + diff --git a/src/main/java/uy/supap/model/entity/User.java b/src/main/java/uy/supap/model/entity/User.java new file mode 100644 index 0000000..865b175 --- /dev/null +++ b/src/main/java/uy/supap/model/entity/User.java @@ -0,0 +1,92 @@ +package uy.supap.model.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +/** + * User entity representing application users. + * + * Supports multiple user types and role-based access control. + */ +@Entity +@Table(name = "users", indexes = { + @Index(name = "idx_users_email", columnList = "email"), + @Index(name = "idx_users_type", columnList = "user_type"), + @Index(name = "idx_users_active", columnList = "active") +}) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 255) + private String email; + + @Column(nullable = false, length = 255) + private String password; // BCrypt hashed + + @Column(name = "first_name", length = 100) + private String firstName; + + @Column(name = "last_name", length = 100) + private String lastName; + + @Column(length = 20) + private String phone; + + @Enumerated(EnumType.STRING) + @Column(name = "user_type", nullable = false, length = 20) + @Builder.Default + private UserType userType = UserType.VISITOR; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_id") + ) + @Builder.Default + private Set roles = new HashSet<>(); + + @Column(name = "membership_start_date") + private LocalDateTime membershipStartDate; + + @Column(name = "membership_end_date") + private LocalDateTime membershipEndDate; + + @Column(nullable = false) + @Builder.Default + private Boolean active = true; + + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * User types for the SUPAP platform. + */ + public enum UserType { + VISITOR, // Non-member, can register for events + MEMBER, // Paid SUPAP member, free event access + STUDENT, // Enrolled in courses + INSTRUCTOR, // Teaching courses + ADMIN // Platform administrator + } +} + diff --git a/src/main/java/uy/supap/repository/RoleRepository.java b/src/main/java/uy/supap/repository/RoleRepository.java new file mode 100644 index 0000000..ba731bf --- /dev/null +++ b/src/main/java/uy/supap/repository/RoleRepository.java @@ -0,0 +1,31 @@ +package uy.supap.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.Role; + +import java.util.Optional; + +/** + * Repository interface for Role entity. + */ +@Repository +public interface RoleRepository extends JpaRepository { + + /** + * Find role by name. + * + * @param name the role name + * @return Optional containing role if found + */ + Optional findByName(Role.RoleName name); + + /** + * Check if a role exists with the given name. + * + * @param name the role name to check + * @return true if role exists, false otherwise + */ + boolean existsByName(Role.RoleName name); +} + diff --git a/src/main/java/uy/supap/repository/UserRepository.java b/src/main/java/uy/supap/repository/UserRepository.java new file mode 100644 index 0000000..9c32705 --- /dev/null +++ b/src/main/java/uy/supap/repository/UserRepository.java @@ -0,0 +1,31 @@ +package uy.supap.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uy.supap.model.entity.User; + +import java.util.Optional; + +/** + * Repository interface for User entity. + */ +@Repository +public interface UserRepository extends JpaRepository { + + /** + * Find user by email address. + * + * @param email the email to search for + * @return Optional containing user if found + */ + Optional findByEmail(String email); + + /** + * Check if a user exists with the given email. + * + * @param email the email to check + * @return true if user exists, false otherwise + */ + boolean existsByEmail(String email); +} + diff --git a/src/main/java/uy/supap/security/JwtAuthenticationFilter.java b/src/main/java/uy/supap/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..242495c --- /dev/null +++ b/src/main/java/uy/supap/security/JwtAuthenticationFilter.java @@ -0,0 +1,76 @@ +package uy.supap.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * JWT Authentication Filter. + * + * Intercepts requests and validates JWT tokens. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider tokenProvider; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + String jwt = getJwtFromRequest(request); + + if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { + String username = tokenProvider.getUsernameFromToken(jwt); + + UserDetails userDetails = userDetailsService.loadUserByUsername(username); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (Exception ex) { + log.error("Could not set user authentication in security context", ex); + } + + filterChain.doFilter(request, response); + } + + /** + * Extract JWT token from request header. + * + * @param request the HTTP request + * @return JWT token string or null + */ + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} + diff --git a/src/main/java/uy/supap/security/JwtTokenProvider.java b/src/main/java/uy/supap/security/JwtTokenProvider.java new file mode 100644 index 0000000..e4ce314 --- /dev/null +++ b/src/main/java/uy/supap/security/JwtTokenProvider.java @@ -0,0 +1,117 @@ +package uy.supap.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import uy.supap.config.JwtConfig; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.stream.Collectors; + +/** + * JWT Token Provider for generating and validating JWT tokens. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtConfig jwtConfig; + + /** + * Generate JWT access token from authentication. + * + * @param authentication the authentication object + * @return JWT token string + */ + public String generateToken(Authentication authentication) { + UserDetails userDetails = (UserDetails) authentication.getPrincipal(); + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtConfig.getExpiration()); + + String authorities = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(userDetails.getUsername()) + .claim("authorities", authorities) + .issuedAt(now) + .expiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + /** + * Generate JWT refresh token. + * + * @param username the username + * @return refresh token string + */ + public String generateRefreshToken(String username) { + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + jwtConfig.getRefreshExpiration()); + + return Jwts.builder() + .subject(username) + .claim("type", "refresh") + .issuedAt(now) + .expiration(expiryDate) + .signWith(getSigningKey()) + .compact(); + } + + /** + * Get username from JWT token. + * + * @param token the JWT token + * @return username + */ + public String getUsernameFromToken(String token) { + Claims claims = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + + return claims.getSubject(); + } + + /** + * Validate JWT token. + * + * @param token the JWT token + * @return true if valid, false otherwise + */ + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token); + return true; + } catch (Exception e) { + log.error("Invalid JWT token: {}", e.getMessage()); + return false; + } + } + + /** + * Get signing key from secret. + * + * @return SecretKey for signing + */ + private SecretKey getSigningKey() { + byte[] keyBytes = jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } +} + diff --git a/src/main/java/uy/supap/security/UserDetailsServiceImpl.java b/src/main/java/uy/supap/security/UserDetailsServiceImpl.java new file mode 100644 index 0000000..598c605 --- /dev/null +++ b/src/main/java/uy/supap/security/UserDetailsServiceImpl.java @@ -0,0 +1,69 @@ +package uy.supap.security; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.GrantedAuthority; +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 org.springframework.transaction.annotation.Transactional; +import uy.supap.model.entity.Role; +import uy.supap.model.entity.User; +import uy.supap.repository.UserRepository; + +import java.util.Collection; +import java.util.stream.Collectors; + +/** + * UserDetailsService implementation for Spring Security. + * + * Loads user details from database for authentication. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + @Transactional(readOnly = true) + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> { + log.warn("User not found with email: {}", email); + return new UsernameNotFoundException("User not found with email: " + email); + }); + + if (!user.getActive()) { + log.warn("Attempt to login with inactive user: {}", email); + throw new UsernameNotFoundException("User account is inactive"); + } + + return User.builder() + .username(user.getEmail()) + .password(user.getPassword()) + .authorities(getAuthorities(user.getRoles())) + .accountExpired(false) + .accountLocked(false) + .credentialsExpired(false) + .disabled(!user.getActive()) + .build(); + } + + /** + * Convert roles to Spring Security authorities. + * + * @param roles the user roles + * @return collection of authorities + */ + private Collection getAuthorities(Collection roles) { + return roles.stream() + .map(role -> new SimpleGrantedAuthority(role.getName().name())) + .collect(Collectors.toList()); + } +} + diff --git a/src/main/java/uy/supap/service/AuthService.java b/src/main/java/uy/supap/service/AuthService.java new file mode 100644 index 0000000..61dec36 --- /dev/null +++ b/src/main/java/uy/supap/service/AuthService.java @@ -0,0 +1,158 @@ +package uy.supap.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import uy.supap.exception.EmailAlreadyExistsException; +import uy.supap.model.dto.request.LoginRequest; +import uy.supap.model.dto.request.RegisterRequest; +import uy.supap.model.dto.response.JwtResponse; +import uy.supap.model.dto.response.UserResponse; +import uy.supap.model.entity.Role; +import uy.supap.model.entity.User; +import uy.supap.repository.RoleRepository; +import uy.supap.repository.UserRepository; +import uy.supap.security.JwtTokenProvider; + +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Authentication service. + * + * Handles user registration, login, and JWT token generation. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final RoleRepository roleRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final JwtTokenProvider tokenProvider; + + /** + * Register a new user. + * + * @param request registration request + * @return JWT response with token + */ + @Transactional + public JwtResponse register(RegisterRequest request) { + log.info("Registering new user: {}", request.getEmail()); + + // Check if email already exists + if (userRepository.existsByEmail(request.getEmail())) { + throw new EmailAlreadyExistsException("Email already exists: " + request.getEmail()); + } + + // Create new user + User user = User.builder() + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .firstName(request.getFirstName()) + .lastName(request.getLastName()) + .phone(request.getPhone()) + .userType(User.UserType.VISITOR) + .active(true) + .roles(new HashSet<>()) + .build(); + + // Assign default role + Role userRole = roleRepository.findByName(Role.RoleName.ROLE_USER) + .orElseThrow(() -> new RuntimeException("Default role ROLE_USER not found")); + user.getRoles().add(userRole); + + User savedUser = userRepository.save(user); + log.info("User registered successfully with id: {}", savedUser.getId()); + + // Generate tokens + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + request.getEmail(), + request.getPassword())); + + return generateJwtResponse(authentication, savedUser); + } + + /** + * Authenticate user and generate JWT token. + * + * @param request login request + * @return JWT response with token + */ + @Transactional(readOnly = true) + public JwtResponse login(LoginRequest request) { + log.info("Login attempt for user: {}", request.getEmail()); + + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken( + request.getEmail(), + request.getPassword())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(() -> new RuntimeException("User not found after authentication")); + + log.info("User logged in successfully: {}", request.getEmail()); + return generateJwtResponse(authentication, user); + + } catch (BadCredentialsException e) { + log.warn("Invalid credentials for user: {}", request.getEmail()); + throw new BadCredentialsException("Invalid email or password"); + } + } + + /** + * Generate JWT response from authentication. + * + * @param authentication the authentication object + * @param user the user entity + * @return JWT response + */ + private JwtResponse generateJwtResponse(Authentication authentication, User user) { + String accessToken = tokenProvider.generateToken(authentication); + String refreshToken = tokenProvider.generateRefreshToken(user.getEmail()); + + return JwtResponse.builder() + .token(accessToken) + .refreshToken(refreshToken) + .id(user.getId()) + .email(user.getEmail()) + .firstName(user.getFirstName()) + .lastName(user.getLastName()) + .roles(user.getRoles().stream() + .map(role -> role.getName().name()) + .collect(Collectors.toList())) + .build(); + } + + /** + * Get current authenticated user. + * + * @return user response + */ + @Transactional(readOnly = true) + public UserResponse getCurrentUser() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String email = authentication.getName(); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + return UserResponse.fromEntity(user); + } +} + diff --git a/src/main/resources/db/migration/V1__initial_schema.sql b/src/main/resources/db/migration/V1__initial_schema.sql new file mode 100644 index 0000000..fd9fcd4 --- /dev/null +++ b/src/main/resources/db/migration/V1__initial_schema.sql @@ -0,0 +1,40 @@ +-- SUPAP Backend - Initial Database Schema +-- Phase 1: Core Backend - Users and Roles + +-- Roles table +CREATE TABLE roles ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + description VARCHAR(255) +); + +-- Users table +CREATE TABLE users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password VARCHAR(255) NOT NULL, + first_name VARCHAR(100), + last_name VARCHAR(100), + phone VARCHAR(20), + user_type VARCHAR(20) NOT NULL DEFAULT 'VISITOR', + membership_start_date TIMESTAMP, + membership_end_date TIMESTAMP, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- User-Role many-to-many relationship +CREATE TABLE user_roles ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, role_id) +); + +-- Indexes for performance +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_type ON users(user_type); +CREATE INDEX idx_users_active ON users(active); +CREATE INDEX idx_user_roles_user ON user_roles(user_id); +CREATE INDEX idx_user_roles_role ON user_roles(role_id); + diff --git a/src/main/resources/db/migration/V2__seed_roles.sql b/src/main/resources/db/migration/V2__seed_roles.sql new file mode 100644 index 0000000..5646146 --- /dev/null +++ b/src/main/resources/db/migration/V2__seed_roles.sql @@ -0,0 +1,12 @@ +-- SUPAP Backend - Seed Initial Roles +-- Phase 1: Core Backend - Initial role data + +INSERT INTO roles (name, description) VALUES + ('ROLE_USER', 'Basic user access - can view public content and register for events'), + ('ROLE_MEMBER', 'SUPAP member - free event access and member pricing'), + ('ROLE_STUDENT', 'Student role - can enroll in courses and submit assignments'), + ('ROLE_INSTRUCTOR', 'Instructor role - can manage own courses and grade assignments'), + ('ROLE_ADMIN', 'Administrator - full access to manage all content'), + ('ROLE_SUPER_ADMIN', 'Super Administrator - system-level access') +ON CONFLICT (name) DO NOTHING; +