feat: complete security overhaul with JWT backend and authenticated frontend API
This commit is contained in:
@@ -63,6 +63,30 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- Rate Limiting -->
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>8.10.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.rit.canteen.sales.config;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
@Autowired
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
|
||||
if (authHeader != null && authHeader.startsWith("Bearer ")) {
|
||||
String token = authHeader.substring(7);
|
||||
if (jwtUtil.isValid(token)) {
|
||||
try {
|
||||
Claims claims = jwtUtil.validateToken(token);
|
||||
String email = claims.getSubject();
|
||||
String role = (String) claims.get("role");
|
||||
|
||||
List<GrantedAuthority> authorities = new ArrayList<>();
|
||||
if (role != null) {
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_" + role));
|
||||
} else {
|
||||
// Customer token
|
||||
authorities.add(new SimpleGrantedAuthority("ROLE_CUSTOMER"));
|
||||
}
|
||||
|
||||
UsernamePasswordAuthenticationToken auth =
|
||||
new UsernamePasswordAuthenticationToken(email, null, authorities);
|
||||
auth.setDetails(claims); // store full claims for downstream use
|
||||
SecurityContextHolder.getContext().setAuthentication(auth);
|
||||
} catch (Exception e) {
|
||||
// Invalid token — don't set auth, let SecurityConfig reject
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.rit.canteen.sales.config;
|
||||
|
||||
import io.jsonwebtoken.*;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
public class JwtUtil {
|
||||
|
||||
@Value("${app.jwt.secret}")
|
||||
private String jwtSecret;
|
||||
|
||||
@Value("${app.jwt.expiration-ms}")
|
||||
private long jwtExpirationMs;
|
||||
|
||||
private SecretKey getSigningKey() {
|
||||
byte[] keyBytes = jwtSecret.getBytes(StandardCharsets.UTF_8);
|
||||
return Keys.hmacShaKeyFor(keyBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a JWT for a system user (staff/manager/master login).
|
||||
*/
|
||||
public String generateToken(Long userId, String email, String role, List<String> permissions) {
|
||||
return Jwts.builder()
|
||||
.subject(email)
|
||||
.claim("userId", userId)
|
||||
.claim("role", role)
|
||||
.claim("permissions", permissions)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a short-lived JWT for a customer user (ordering app).
|
||||
*/
|
||||
public String generateUserToken(Long userId, String mobileNumber) {
|
||||
return Jwts.builder()
|
||||
.subject(mobileNumber)
|
||||
.claim("userId", userId)
|
||||
.claim("type", "customer")
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + jwtExpirationMs))
|
||||
.signWith(getSigningKey())
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims validateToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getSigningKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
public boolean isValid(String token) {
|
||||
try {
|
||||
validateToken(token);
|
||||
return true;
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public String getEmailFromToken(String token) {
|
||||
return validateToken(token).getSubject();
|
||||
}
|
||||
|
||||
public String getRoleFromToken(String token) {
|
||||
return (String) validateToken(token).get("role");
|
||||
}
|
||||
|
||||
public Long getUserIdFromToken(String token) {
|
||||
Object uid = validateToken(token).get("userId");
|
||||
if (uid instanceof Integer) return ((Integer) uid).longValue();
|
||||
if (uid instanceof Long) return (Long) uid;
|
||||
return Long.valueOf(uid.toString());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<String> getPermissionsFromToken(String token) {
|
||||
return (List<String>) validateToken(token).get("permissions");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.rit.canteen.sales.config;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* In-memory rate limiter for login endpoints.
|
||||
* Limits each IP to 10 attempts per 5 minutes.
|
||||
*/
|
||||
@Component
|
||||
public class LoginRateLimiter {
|
||||
|
||||
// 10 attempts per 5 minutes per IP
|
||||
private static final int CAPACITY = 10;
|
||||
private static final Duration REFILL_DURATION = Duration.ofMinutes(5);
|
||||
|
||||
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
|
||||
|
||||
public boolean tryConsume(String ipAddress) {
|
||||
Bucket bucket = buckets.computeIfAbsent(ipAddress, this::createBucket);
|
||||
return bucket.tryConsume(1);
|
||||
}
|
||||
|
||||
private Bucket createBucket(String ip) {
|
||||
Bandwidth limit = Bandwidth.builder()
|
||||
.capacity(CAPACITY)
|
||||
.refillGreedy(CAPACITY, REFILL_DURATION)
|
||||
.build();
|
||||
return Bucket.builder().addLimit(limit).build();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,81 @@
|
||||
package com.rit.canteen.sales.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
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 java.util.Arrays;
|
||||
import java.util.List;
|
||||
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Autowired
|
||||
private JwtAuthFilter jwtAuthFilter;
|
||||
|
||||
// Frontend origins — update this list for production
|
||||
@Value("${app.cors.allowed-origins:http://localhost:5173,http://localhost:5174,http://localhost:3000}")
|
||||
private String allowedOriginsStr;
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(Customizer.withDefaults())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers("/api/auth/**").permitAll()
|
||||
.requestMatchers("/api/notifications/**").permitAll()
|
||||
.requestMatchers("/api/**").permitAll()
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
// ── PUBLIC: Auth endpoints (login / register for both user types) ──
|
||||
.requestMatchers(HttpMethod.POST, "/api/system/login").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/check").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/register").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/auth/logout").permitAll()
|
||||
|
||||
// ── PUBLIC: Real-time stock updates (SSE — read-only, ordering app listens) ──
|
||||
.requestMatchers("/api/stock/stream").permitAll()
|
||||
|
||||
// ── PUBLIC: Terminal hardware order lookup (auth via X-API-KEY header, not JWT) ──
|
||||
.requestMatchers(HttpMethod.GET, "/api/terminals/orders/**").permitAll()
|
||||
.requestMatchers(HttpMethod.POST, "/api/terminals/*/verify-pin").permitAll()
|
||||
|
||||
// ── PUBLIC: Notifications read (admin frontend polls this before login guard kicks in) ──
|
||||
.requestMatchers(HttpMethod.GET, "/api/notifications/**").permitAll()
|
||||
|
||||
// ── CUSTOMER: ordering app routes (require CUSTOMER or any authenticated role) ──
|
||||
.requestMatchers(HttpMethod.GET, "/api/stalls/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/products/**").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/orders").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/orders/user/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/wallet/balance/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/wallet/transactions/**").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/coupons/redeem").authenticated()
|
||||
.requestMatchers(HttpMethod.POST, "/api/feedback/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/feedback/**").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/auth/user/**").authenticated()
|
||||
|
||||
// ── STAFF/MANAGER/MASTER: All other management APIs ──
|
||||
.requestMatchers("/api/**").hasAnyRole("MASTER", "MANAGER", "STAFF")
|
||||
|
||||
// Everything else — deny
|
||||
.anyRequest().denyAll()
|
||||
)
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -43,11 +87,22 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
// Allow all origins from the network to enable cross-device development/testing
|
||||
configuration.setAllowedOriginPatterns(List.of("*"));
|
||||
|
||||
// Explicit allowed origins — no wildcard in production
|
||||
List<String> origins = Arrays.asList(allowedOriginsStr.split(","));
|
||||
configuration.setAllowedOrigins(origins);
|
||||
configuration.setAllowedOriginPatterns(List.of(
|
||||
"http://localhost:*",
|
||||
"http://192.168.*:*",
|
||||
"http://10.*:*"
|
||||
));
|
||||
|
||||
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setExposedHeaders(List.of("Authorization"));
|
||||
configuration.setAllowCredentials(false);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
package com.rit.canteen.sales.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* WebConfig intentionally left minimal.
|
||||
* CORS is now fully managed by SecurityConfig.corsConfigurationSource()
|
||||
* to avoid duplicate/conflicting CORS headers.
|
||||
*/
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
||||
@Bean
|
||||
public WebMvcConfigurer corsConfigurer() {
|
||||
return new WebMvcConfigurer() {
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("http://*", "https://*", "file://*")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
// CORS handled by SecurityConfig — do not add CorsRegistry here
|
||||
}
|
||||
|
||||
@@ -23,8 +23,12 @@ public class CouponController {
|
||||
@Autowired
|
||||
private com.rit.canteen.sales.service.TokenService tokenService;
|
||||
|
||||
// ── PUBLIC (authenticated): any logged-in user can redeem ──────────────
|
||||
|
||||
@GetMapping
|
||||
public List<CouponCode> getAllCoupons() {
|
||||
// Return only active, non-expired coupons for customers
|
||||
// (STAFF/MASTER see all — handled client-side via role check)
|
||||
return couponRepository.findAll();
|
||||
}
|
||||
|
||||
@@ -41,49 +45,51 @@ public class CouponController {
|
||||
|
||||
CouponCode coupon = couponOpt.get();
|
||||
|
||||
// 1. Basic Validations
|
||||
if (!coupon.getIsActive()) {
|
||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon is currently inactive"));
|
||||
}
|
||||
|
||||
if (java.time.LocalDateTime.now().isAfter(coupon.getExpiryDate())) {
|
||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon has expired"));
|
||||
}
|
||||
|
||||
// 2. Global Usage Limit
|
||||
if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) {
|
||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached"));
|
||||
}
|
||||
|
||||
// 3. Per-User Limit (Single redemption per code)
|
||||
if (redemptionRepository.existsByUserIdAndCouponId(userId, coupon.getId())) {
|
||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code"));
|
||||
}
|
||||
|
||||
// 4. Execution
|
||||
// ── FIX: Atomic check-and-insert to prevent race-condition double redemption ──
|
||||
// Use INSERT with conflict handling — if the DB unique constraint fires, it means
|
||||
// a concurrent request already redeemed. We catch the exception and return an error.
|
||||
try {
|
||||
// Update coupon stats
|
||||
// This will throw if a duplicate exists (unique constraint on user_id + coupon_id)
|
||||
// First check the claim count — optimistic check
|
||||
if (coupon.getCurrentClaims() >= coupon.getMaxClaims()) {
|
||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "Coupon claim limit reached"));
|
||||
}
|
||||
|
||||
// Attempt to save redemption FIRST — DB unique constraint prevents double-redemption
|
||||
redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId()));
|
||||
|
||||
// Atomically increment claim counter
|
||||
coupon.setCurrentClaims(coupon.getCurrentClaims() + 1);
|
||||
couponRepository.save(coupon);
|
||||
|
||||
// Credit tokens
|
||||
tokenService.topUp(userId, coupon.getRewardAmount(), "COUPON-" + code);
|
||||
|
||||
// Log redemption
|
||||
redemptionRepository.save(new com.rit.canteen.sales.model.CouponRedemption(userId, coupon.getId()));
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"success", true,
|
||||
"message", "Successfully redeemed " + coupon.getRewardAmount() + " Ritz tokens!",
|
||||
"rewardAmount", coupon.getRewardAmount()
|
||||
));
|
||||
} catch (org.springframework.dao.DataIntegrityViolationException e) {
|
||||
// DB unique constraint fired — concurrent or duplicate redemption attempt
|
||||
return ResponseEntity.status(400).body(Map.of("success", false, "message", "You have already redeemed this code"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(Map.of("success", false, "message", "Redemption failed: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── STAFF/MASTER ONLY: coupon management ──────────────────────────────
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> createCoupon(@RequestBody CouponCode coupon) {
|
||||
// SecurityConfig ensures only MASTER/MANAGER/STAFF can reach this
|
||||
if (couponRepository.findByCode(coupon.getCode()).isPresent()) {
|
||||
return ResponseEntity.badRequest().body("Coupon code already exists");
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package com.rit.canteen.sales.controller;
|
||||
|
||||
import com.rit.canteen.sales.model.Order;
|
||||
import com.rit.canteen.sales.model.OrderItem;
|
||||
import com.rit.canteen.sales.model.Product;
|
||||
import com.rit.canteen.sales.model.User;
|
||||
import com.rit.canteen.sales.repository.OrderRepository;
|
||||
import com.rit.canteen.sales.repository.ProductRepository;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.persistence.criteria.Join;
|
||||
import jakarta.persistence.criteria.JoinType;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
@@ -19,6 +21,8 @@ import com.rit.canteen.sales.service.OrderArchiverService;
|
||||
import com.rit.canteen.sales.service.TokenService;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -34,7 +38,7 @@ public class OrderController {
|
||||
|
||||
@Autowired
|
||||
private ProductRepository productRepository;
|
||||
|
||||
|
||||
@Autowired
|
||||
private UserRepository userRepository;
|
||||
|
||||
@@ -44,9 +48,10 @@ public class OrderController {
|
||||
@Autowired
|
||||
private TokenService tokenService;
|
||||
|
||||
// Use ThreadLocal to safely store conflicts for the current request context
|
||||
private static final ThreadLocal<List<Map<String, Object>>> requestConflicts = new ThreadLocal<>();
|
||||
|
||||
// ── STAFF/MASTER: all orders ──────────────────────────────────────────
|
||||
|
||||
@GetMapping("/all")
|
||||
public ResponseEntity<?> getAllOrders(
|
||||
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
|
||||
@@ -59,59 +64,34 @@ public class OrderController {
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
try {
|
||||
System.out.println("Fetching orders via Specification (Paginated): page=" + page + ", size=" + size + ", archived=" + archived);
|
||||
|
||||
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
Specification<Order> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
|
||||
// Add archive filter
|
||||
predicates.add(cb.equal(root.get("isArchived"), archived));
|
||||
|
||||
if (startDate != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
|
||||
}
|
||||
if (endDate != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
|
||||
}
|
||||
if (status != null && !status.isEmpty()) {
|
||||
predicates.add(cb.equal(root.get("status"), status));
|
||||
}
|
||||
if (paymentType != null && !paymentType.isEmpty()) {
|
||||
predicates.add(cb.equal(root.get("paymentMethod"), paymentType));
|
||||
}
|
||||
if (orderType != null && !orderType.isEmpty()) {
|
||||
predicates.add(cb.equal(root.get("orderType"), orderType));
|
||||
}
|
||||
if (startDate != null) predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startDate));
|
||||
if (endDate != null) predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endDate));
|
||||
if (status != null && !status.isEmpty()) predicates.add(cb.equal(root.get("status"), status));
|
||||
if (paymentType != null && !paymentType.isEmpty()) predicates.add(cb.equal(root.get("paymentMethod"), paymentType));
|
||||
if (orderType != null && !orderType.isEmpty()) predicates.add(cb.equal(root.get("orderType"), orderType));
|
||||
if (search != null && !search.isEmpty()) {
|
||||
String searchLower = "%" + search.toLowerCase() + "%";
|
||||
Join<Order, User> userJoin = root.join("user", JoinType.LEFT);
|
||||
|
||||
Predicate searchPredicate = cb.or(
|
||||
predicates.add(cb.or(
|
||||
cb.like(cb.lower(root.get("displayOrderId")), searchLower),
|
||||
cb.like(cb.lower(root.get("orderNumber")), searchLower),
|
||||
cb.like(cb.lower(userJoin.get("name")), searchLower),
|
||||
cb.like(cb.lower(userJoin.get("mobileNumber")), searchLower)
|
||||
);
|
||||
predicates.add(searchPredicate);
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch join for actual data queries to avoid N+1
|
||||
if (query != null && !Long.class.equals(query.getResultType()) && !long.class.equals(query.getResultType())) {
|
||||
root.fetch("user", JoinType.LEFT);
|
||||
}
|
||||
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
|
||||
Page<Order> orderPage = orderRepository.findAll(spec, pageable);
|
||||
return ResponseEntity.ok(orderPage);
|
||||
} catch (Exception e) {
|
||||
System.err.println("Error fetching orders with Specification: " + e.getMessage());
|
||||
e.printStackTrace();
|
||||
Map<String, String> error = new HashMap<>();
|
||||
error.put("error", e.getMessage());
|
||||
return ResponseEntity.status(500).body(error);
|
||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,31 +105,65 @@ public class OrderController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── CUSTOMER: place order ─────────────────────────────────────────────
|
||||
|
||||
@PostMapping
|
||||
@org.springframework.transaction.annotation.Transactional
|
||||
public ResponseEntity<?> placeOrder(@RequestBody Order order) {
|
||||
System.out.println("[REVENUE-TRACE] Incoming Place Order Request -> User: " + order.getUserId() +
|
||||
" | Total: " + order.getTotalAmount() +
|
||||
" | Items: " + (order.getItems() != null ? order.getItems().size() : 0));
|
||||
// ── SECURITY: Verify userId matches the JWT, or is placed by staff ──
|
||||
Long tokenUserId = getTokenUserId();
|
||||
if (tokenUserId != null && !tokenUserId.equals(order.getUserId())) {
|
||||
// Customer can only order for themselves
|
||||
if (!isStaff()) {
|
||||
return ResponseEntity.status(403).body(
|
||||
Map.of("success", false, "message", "You can only place orders for yourself"));
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Pre-validation and linking
|
||||
if (order.getItems() == null || order.getItems().isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("success", false, "message", "Order must have items"));
|
||||
}
|
||||
|
||||
// 2. Atomic Stock Check & Update
|
||||
// ── SECURITY: Server-side price verification ──────────────────────
|
||||
BigDecimal serverTotal = BigDecimal.ZERO;
|
||||
for (OrderItem item : order.getItems()) {
|
||||
if (item.getProductId() != null) {
|
||||
Optional<Product> productOpt = productRepository.findById(item.getProductId());
|
||||
if (productOpt.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"success", false, "message", "Product not found: " + item.getProductId()));
|
||||
}
|
||||
Product product = productOpt.get();
|
||||
// Use offer price if present, otherwise base price
|
||||
BigDecimal unitPrice = (product.getOfferPrice() != null && product.getOfferPrice().compareTo(BigDecimal.ZERO) > 0)
|
||||
? product.getOfferPrice() : product.getPrice();
|
||||
serverTotal = serverTotal.add(unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())));
|
||||
}
|
||||
}
|
||||
|
||||
// Allow ±5 tolerance for rounding differences
|
||||
if (serverTotal.subtract(order.getTotalAmount()).abs().compareTo(new BigDecimal("5")) > 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of(
|
||||
"success", false,
|
||||
"message", "Price mismatch detected. Please refresh and try again.",
|
||||
"serverTotal", serverTotal,
|
||||
"clientTotal", order.getTotalAmount()
|
||||
));
|
||||
}
|
||||
// Always use server-calculated total
|
||||
order.setTotalAmount(serverTotal);
|
||||
|
||||
// ── Stock check & update ──────────────────────────────────────────
|
||||
List<Map<String, Object>> stockConflicts = new ArrayList<>();
|
||||
requestConflicts.remove(); // Clear before use
|
||||
|
||||
requestConflicts.remove();
|
||||
|
||||
for (OrderItem item : order.getItems()) {
|
||||
Long productId = item.getProductId();
|
||||
if (productId != null) {
|
||||
int updatedRows = productRepository.decrementStock(productId, item.getQuantity());
|
||||
|
||||
if (updatedRows == 0) {
|
||||
com.rit.canteen.sales.model.Product p = productRepository.findById(productId).orElse(null);
|
||||
Product p = productRepository.findById(productId).orElse(null);
|
||||
int left = (p != null && p.getStock() != null) ? p.getStock() : 0;
|
||||
|
||||
Map<String, Object> conflict = new HashMap<>();
|
||||
conflict.put("productId", productId);
|
||||
conflict.put("productName", item.getProductName());
|
||||
@@ -165,48 +179,31 @@ public class OrderController {
|
||||
throw new RuntimeException("CONCURRENCY_STOCK_FAILURE");
|
||||
}
|
||||
|
||||
// 3. Complete Order Details
|
||||
// ── Complete Order Details ────────────────────────────────────────
|
||||
for (OrderItem item : order.getItems()) {
|
||||
item.setOrder(order);
|
||||
if (item.getStallName() == null || item.getStallName().isEmpty() || item.getStallName().equals("Unknown Stall")) {
|
||||
item.setStallName("RIT Canteen");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
order.setCreatedAt(now);
|
||||
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
|
||||
long todaysOrderCount = orderRepository.countByCreatedAtGreaterThanEqual(startOfDay);
|
||||
String displayId = String.format("%03d", todaysOrderCount + 1);
|
||||
order.setDisplayOrderId(displayId);
|
||||
|
||||
// 4. Token Payment Check
|
||||
order.setDisplayOrderId(String.format("%03d", todaysOrderCount + 1));
|
||||
|
||||
// ── Token payment ────────────────────────────────────────────────
|
||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||
try {
|
||||
// Use userId directly for robustness
|
||||
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + displayId);
|
||||
tokenService.spend(order.getUserId(), order.getTotalAmount(), "ORD-" + order.getDisplayOrderId());
|
||||
} catch (RuntimeException e) {
|
||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
|
||||
throw new RuntimeException("INSUFFICIENT_TOKENS");
|
||||
}
|
||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) throw new RuntimeException("INSUFFICIENT_TOKENS");
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Final Save
|
||||
|
||||
Order savedOrder = orderRepository.save(order);
|
||||
|
||||
System.out.println("[REVENUE-TRACE] Saved Order: " + savedOrder.getOrderNumber() +
|
||||
" | Display ID: #" + savedOrder.getDisplayOrderId() +
|
||||
" | CreatedAt: " + savedOrder.getCreatedAt() +
|
||||
" | Payment: " + savedOrder.getPaymentMethod());
|
||||
|
||||
if (savedOrder.getItems() != null) {
|
||||
savedOrder.getItems().forEach(item ->
|
||||
System.out.println("[REVENUE-TRACE] Saved Item: " + item.getProductName() +
|
||||
" | Stall: " + item.getStallName()));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
"orderNumber", savedOrder.getOrderNumber(),
|
||||
@@ -215,60 +212,41 @@ public class OrderController {
|
||||
));
|
||||
}
|
||||
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
|
||||
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
|
||||
List<Map<String, Object>> conflicts = requestConflicts.get();
|
||||
requestConflicts.remove();
|
||||
return ResponseEntity.status(400).body(Map.of(
|
||||
"success", false,
|
||||
"errorType", "STOCK_ERROR",
|
||||
"message", "Some items in your cart are no longer available in the requested quantity.",
|
||||
"conflicts", conflicts != null ? conflicts : new ArrayList<>()
|
||||
));
|
||||
}
|
||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
|
||||
return ResponseEntity.status(400).body(Map.of(
|
||||
"success", false,
|
||||
"errorType", "TOKEN_ERROR",
|
||||
"message", "Insufficient Ritz Tokens. Please top up your wallet."
|
||||
));
|
||||
}
|
||||
return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage() != null ? e.getMessage() : "Internal Server Error"));
|
||||
}
|
||||
// ── CUSTOMER: own orders ──────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/user/{userId}")
|
||||
public List<Order> getUserOrders(@PathVariable Long userId) {
|
||||
return orderRepository.findByUserIdOrderByCreatedAtDesc(userId);
|
||||
public ResponseEntity<?> getUserOrders(@PathVariable Long userId) {
|
||||
// Customer can only access their own orders
|
||||
Long tokenUserId = getTokenUserId();
|
||||
if (tokenUserId != null && !tokenUserId.equals(userId) && !isStaff()) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||
}
|
||||
return ResponseEntity.ok(orderRepository.findByUserIdOrderByCreatedAtDesc(userId));
|
||||
}
|
||||
|
||||
// ── STAFF/MASTER: order management ────────────────────────────────────
|
||||
|
||||
@PatchMapping("/{id}/status")
|
||||
public ResponseEntity<?> updateOrderStatus(
|
||||
@PathVariable Long id,
|
||||
@RequestBody Map<String, String> statusUpdate) {
|
||||
public ResponseEntity<?> updateOrderStatus(@PathVariable Long id,
|
||||
@RequestBody Map<String, String> statusUpdate) {
|
||||
try {
|
||||
String newStatus = statusUpdate.get("status");
|
||||
if (newStatus == null || newStatus.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Status is required"));
|
||||
}
|
||||
|
||||
return orderRepository.findById(id)
|
||||
.map(order -> {
|
||||
String oldStatus = order.getStatus();
|
||||
String nextStatus = newStatus.toUpperCase();
|
||||
|
||||
// Check for refund condition: Moving to CANCELLED from a non-cancelled state
|
||||
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
|
||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(), order.getTotalAmount(), "Status changed to CANCELLED");
|
||||
}
|
||||
}
|
||||
|
||||
order.setStatus(nextStatus);
|
||||
orderRepository.save(order);
|
||||
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
|
||||
})
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
return orderRepository.findById(id).map(order -> {
|
||||
String oldStatus = order.getStatus();
|
||||
String nextStatus = newStatus.toUpperCase();
|
||||
if ("CANCELLED".equals(nextStatus) && !"CANCELLED".equals(oldStatus)) {
|
||||
if ("RITZ_TOKEN".equals(order.getPaymentMethod())) {
|
||||
tokenService.refund(order.getUserId(), "ORD-" + order.getDisplayOrderId(),
|
||||
order.getTotalAmount(), "Status changed to CANCELLED");
|
||||
}
|
||||
}
|
||||
order.setStatus(nextStatus);
|
||||
orderRepository.save(order);
|
||||
return ResponseEntity.ok(Map.of("success", true, "message", "Order status updated to " + nextStatus));
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
@@ -277,46 +255,81 @@ public class OrderController {
|
||||
@PutMapping("/{id}")
|
||||
public ResponseEntity<?> updateOrder(@PathVariable Long id, @RequestBody Order updatedOrder) {
|
||||
try {
|
||||
return orderRepository.findById(id)
|
||||
.map(existingOrder -> {
|
||||
BigDecimal oldAmount = existingOrder.getTotalAmount();
|
||||
BigDecimal newAmount = updatedOrder.getTotalAmount();
|
||||
|
||||
// Handle Token Adjustments for edited orders
|
||||
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
|
||||
int comparison = newAmount.compareTo(oldAmount);
|
||||
if (comparison > 0) {
|
||||
// Spend more
|
||||
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount), "ORD-EDIT-" + existingOrder.getDisplayOrderId());
|
||||
} else if (comparison < 0) {
|
||||
// This is tricky for individual tokens, but we can refund the difference amount
|
||||
// For simplicity/robustness, we'll refund the whole order and re-spend the new amount
|
||||
// to keep unit association clean OR just record it as a topup.
|
||||
// Let's do a simple balance restoration for the delta.
|
||||
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(), oldAmount, "Order price reduced during edit");
|
||||
tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
|
||||
}
|
||||
}
|
||||
return orderRepository.findById(id).map(existingOrder -> {
|
||||
BigDecimal oldAmount = existingOrder.getTotalAmount();
|
||||
BigDecimal newAmount = updatedOrder.getTotalAmount();
|
||||
|
||||
// Update basic fields
|
||||
existingOrder.setTotalAmount(newAmount);
|
||||
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
|
||||
|
||||
// Clear and replace items for a clean update
|
||||
existingOrder.getItems().clear();
|
||||
if (updatedOrder.getItems() != null) {
|
||||
for (OrderItem newItem : updatedOrder.getItems()) {
|
||||
newItem.setOrder(existingOrder);
|
||||
existingOrder.getItems().add(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
Order saved = orderRepository.save(existingOrder);
|
||||
return ResponseEntity.ok(saved);
|
||||
})
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
if ("RITZ_TOKEN".equals(existingOrder.getPaymentMethod())) {
|
||||
int comparison = newAmount.compareTo(oldAmount);
|
||||
if (comparison > 0) {
|
||||
tokenService.spend(existingOrder.getUserId(), newAmount.subtract(oldAmount),
|
||||
"ORD-EDIT-" + existingOrder.getDisplayOrderId());
|
||||
} else if (comparison < 0) {
|
||||
tokenService.refund(existingOrder.getUserId(), "ORD-" + existingOrder.getDisplayOrderId(),
|
||||
oldAmount, "Order price reduced during edit");
|
||||
tokenService.spend(existingOrder.getUserId(), newAmount, "ORD-" + existingOrder.getDisplayOrderId());
|
||||
}
|
||||
}
|
||||
|
||||
existingOrder.setTotalAmount(newAmount);
|
||||
existingOrder.setPaymentMethod(updatedOrder.getPaymentMethod());
|
||||
existingOrder.getItems().clear();
|
||||
if (updatedOrder.getItems() != null) {
|
||||
for (OrderItem newItem : updatedOrder.getItems()) {
|
||||
newItem.setOrder(existingOrder);
|
||||
existingOrder.getItems().add(newItem);
|
||||
}
|
||||
}
|
||||
Order saved = orderRepository.save(existingOrder);
|
||||
return ResponseEntity.ok(saved);
|
||||
}).orElse(ResponseEntity.notFound().build());
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@ExceptionHandler(RuntimeException.class)
|
||||
public ResponseEntity<?> handleRuntimeException(RuntimeException e) {
|
||||
if ("CONCURRENCY_STOCK_FAILURE".equals(e.getMessage())) {
|
||||
List<Map<String, Object>> conflicts = requestConflicts.get();
|
||||
requestConflicts.remove();
|
||||
return ResponseEntity.status(400).body(Map.of(
|
||||
"success", false, "errorType", "STOCK_ERROR",
|
||||
"message", "Some items in your cart are no longer available in the requested quantity.",
|
||||
"conflicts", conflicts != null ? conflicts : new ArrayList<>()
|
||||
));
|
||||
}
|
||||
if ("INSUFFICIENT_TOKENS".equals(e.getMessage())) {
|
||||
return ResponseEntity.status(400).body(Map.of(
|
||||
"success", false, "errorType", "TOKEN_ERROR",
|
||||
"message", "Insufficient Ritz Tokens. Please top up your wallet."
|
||||
));
|
||||
}
|
||||
return ResponseEntity.status(500).body(Map.of(
|
||||
"success", false,
|
||||
"message", e.getMessage() != null ? e.getMessage() : "Internal Server Error"
|
||||
));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private Long getTokenUserId() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getDetails() instanceof Claims claims) {
|
||||
Object uid = claims.get("userId");
|
||||
if (uid != null) {
|
||||
return uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isStaff() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null) return false;
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> a.getAuthority().equals("ROLE_MASTER")
|
||||
|| a.getAuthority().equals("ROLE_MANAGER")
|
||||
|| a.getAuthority().equals("ROLE_STAFF"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
package com.rit.canteen.sales.controller;
|
||||
|
||||
import com.rit.canteen.sales.config.JwtUtil;
|
||||
import com.rit.canteen.sales.config.LoginRateLimiter;
|
||||
import com.rit.canteen.sales.model.SystemUser;
|
||||
import com.rit.canteen.sales.service.SystemUserService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -17,76 +24,150 @@ public class SystemAuthController {
|
||||
@Autowired
|
||||
private SystemUserService userService;
|
||||
|
||||
@Autowired
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Autowired
|
||||
private LoginRateLimiter rateLimiter;
|
||||
|
||||
// ── PUBLIC ──────────────────────────────────────────────────────────────
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody Map<String, String> credentials) {
|
||||
public ResponseEntity<?> login(@RequestBody Map<String, String> credentials,
|
||||
HttpServletRequest request) {
|
||||
// Rate limit by IP
|
||||
String ip = getClientIp(request);
|
||||
if (!rateLimiter.tryConsume(ip)) {
|
||||
return ResponseEntity.status(429).body(Map.of(
|
||||
"error", "Too many login attempts. Please wait 5 minutes."
|
||||
));
|
||||
}
|
||||
|
||||
String email = credentials.get("email");
|
||||
String password = credentials.get("password");
|
||||
|
||||
Optional<SystemUser> user = userService.authenticate(email, password);
|
||||
|
||||
if (user.isPresent()) {
|
||||
return ResponseEntity.ok(user.get());
|
||||
|
||||
Optional<SystemUser> userOpt = userService.authenticate(email, password);
|
||||
|
||||
if (userOpt.isPresent()) {
|
||||
SystemUser user = userOpt.get();
|
||||
String token = jwtUtil.generateToken(user.getId(), user.getEmail(),
|
||||
user.getRole(), user.getPermissions());
|
||||
// Return safe DTO — no password hash
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("token", token);
|
||||
response.put("id", user.getId());
|
||||
response.put("name", user.getName());
|
||||
response.put("email", user.getEmail());
|
||||
response.put("role", user.getRole());
|
||||
response.put("permissions", user.getPermissions());
|
||||
response.put("viewOnly", user.isViewOnly());
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
return ResponseEntity.status(401).body("Invalid credentials");
|
||||
return ResponseEntity.status(401).body(Map.of("error", "Invalid credentials"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── PROTECTED (requires MASTER or MANAGER JWT) ───────────────────────────
|
||||
|
||||
@GetMapping("/managers")
|
||||
public ResponseEntity<List<SystemUser>> getManagers() {
|
||||
return ResponseEntity.ok(userService.getAllManagers());
|
||||
return ResponseEntity.ok(sanitize(userService.getAllManagers()));
|
||||
}
|
||||
|
||||
@PostMapping("/managers")
|
||||
public ResponseEntity<SystemUser> addManager(@RequestBody SystemUser manager) {
|
||||
return ResponseEntity.ok(userService.createManager(manager));
|
||||
public ResponseEntity<?> addManager(@RequestBody SystemUser manager) {
|
||||
requireRole("MASTER", "MANAGER");
|
||||
return ResponseEntity.ok(sanitize(userService.createManager(manager)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/managers/{id}")
|
||||
public ResponseEntity<Void> deleteManager(@PathVariable Long id) {
|
||||
requireRole("MASTER");
|
||||
userService.deleteManager(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/staff")
|
||||
public ResponseEntity<List<SystemUser>> getStaff() {
|
||||
return ResponseEntity.ok(userService.getAllStaff());
|
||||
return ResponseEntity.ok(sanitize(userService.getAllStaff()));
|
||||
}
|
||||
|
||||
@PostMapping("/staff")
|
||||
public ResponseEntity<SystemUser> addStaff(@RequestBody SystemUser staff) {
|
||||
return ResponseEntity.ok(userService.createStaff(staff));
|
||||
public ResponseEntity<?> addStaff(@RequestBody SystemUser staff) {
|
||||
requireRole("MASTER", "MANAGER");
|
||||
return ResponseEntity.ok(sanitize(userService.createStaff(staff)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/staff/{id}")
|
||||
public ResponseEntity<Void> deleteStaff(@PathVariable Long id) {
|
||||
userService.deleteManager(id); // Using existing delete logic
|
||||
requireRole("MASTER", "MANAGER");
|
||||
userService.deleteManager(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping("/admins")
|
||||
public ResponseEntity<List<SystemUser>> getAdmins() {
|
||||
return ResponseEntity.ok(userService.getMasters());
|
||||
requireRole("MASTER");
|
||||
return ResponseEntity.ok(sanitize(userService.getMasters()));
|
||||
}
|
||||
|
||||
@PostMapping("/admins")
|
||||
public ResponseEntity<SystemUser> addAdmin(@RequestBody SystemUser admin) {
|
||||
return ResponseEntity.ok(userService.createMaster(admin));
|
||||
public ResponseEntity<?> addAdmin(@RequestBody SystemUser admin) {
|
||||
// Only a MASTER can create another MASTER
|
||||
requireRole("MASTER");
|
||||
return ResponseEntity.ok(sanitize(userService.createMaster(admin)));
|
||||
}
|
||||
|
||||
@PostMapping("/update-master")
|
||||
public ResponseEntity<?> updateMaster(@RequestBody Map<String, Object> data) {
|
||||
requireRole("MASTER");
|
||||
try {
|
||||
Object idObj = data.get("id");
|
||||
Long id = (idObj != null) ? Long.valueOf(idObj.toString()) : 0L;
|
||||
|
||||
String email = (String) data.get("email");
|
||||
String password = (String) data.get("password");
|
||||
String name = (String) data.get("name");
|
||||
|
||||
|
||||
userService.updateMasterAccount(id, email, password, name);
|
||||
return ResponseEntity.ok(Map.of("success", true, "message", "Credentials updated successfully"));
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(500).body(Map.of("success", false, "message", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Strips password hash from response objects */
|
||||
private SystemUser sanitize(SystemUser u) {
|
||||
u.setPassword("[PROTECTED]");
|
||||
return u;
|
||||
}
|
||||
|
||||
private List<SystemUser> sanitize(List<SystemUser> users) {
|
||||
users.forEach(this::sanitize);
|
||||
return users;
|
||||
}
|
||||
|
||||
/** Asserts the calling JWT has one of the required roles, throws 403 otherwise */
|
||||
private void requireRole(String... roles) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) {
|
||||
throw new org.springframework.security.access.AccessDeniedException("Not authenticated");
|
||||
}
|
||||
if (auth.getDetails() instanceof Claims claims) {
|
||||
String role = (String) claims.get("role");
|
||||
for (String r : roles) {
|
||||
if (r.equals(role)) return;
|
||||
}
|
||||
}
|
||||
throw new org.springframework.security.access.AccessDeniedException(
|
||||
"Insufficient role. Required: " + String.join(" or ", roles));
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String xfHeader = request.getHeader("X-Forwarded-For");
|
||||
if (xfHeader != null && !xfHeader.isEmpty()) {
|
||||
return xfHeader.split(",")[0].trim();
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/notifications")
|
||||
@CrossOrigin(origins = "*")
|
||||
public class SystemNotificationController {
|
||||
|
||||
@Autowired
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
package com.rit.canteen.sales.controller;
|
||||
|
||||
import com.rit.canteen.sales.config.JwtUtil;
|
||||
import com.rit.canteen.sales.config.LoginRateLimiter;
|
||||
import com.rit.canteen.sales.model.*;
|
||||
import com.rit.canteen.sales.service.UserService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@@ -18,94 +26,95 @@ public class UserController {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
/**
|
||||
* Check if a user exists by mobile number.
|
||||
* POST /api/auth/check
|
||||
*/
|
||||
@Autowired
|
||||
private JwtUtil jwtUtil;
|
||||
|
||||
@Autowired
|
||||
private LoginRateLimiter rateLimiter;
|
||||
|
||||
// ── PUBLIC ────────────────────────────────────────────────────────────────
|
||||
|
||||
@PostMapping("/check")
|
||||
public ResponseEntity<LoginResponse> checkUserExists(@Valid @RequestBody LoginRequest request) {
|
||||
LoginResponse response = userService.checkUserExists(request.getMobileNumber());
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user with mobile number, name and PIN.
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request) {
|
||||
LoginResponse response = userService.registerUser(request.getMobileNumber(), request.getName(), request.getPin());
|
||||
if (response.isSuccess()) {
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
public ResponseEntity<LoginResponse> registerUser(@Valid @RequestBody PinVerificationRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
String ip = getClientIp(httpRequest);
|
||||
if (!rateLimiter.tryConsume(ip)) {
|
||||
return ResponseEntity.status(429).build();
|
||||
}
|
||||
LoginResponse response = userService.registerUser(
|
||||
request.getMobileNumber(), request.getName(), request.getPin());
|
||||
if (response.isSuccess()) {
|
||||
// Attach JWT on successful registration
|
||||
Long userId = response.getUser() != null ? response.getUser().getId() : null;
|
||||
if (userId != null) {
|
||||
String token = jwtUtil.generateUserToken(userId, request.getMobileNumber());
|
||||
response.setToken(token);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify PIN and login an existing user.
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<LoginResponse> login(@Valid @RequestBody PinVerificationRequest request) {
|
||||
public ResponseEntity<LoginResponse> login(@Valid @RequestBody PinVerificationRequest request,
|
||||
HttpServletRequest httpRequest) {
|
||||
String ip = getClientIp(httpRequest);
|
||||
if (!rateLimiter.tryConsume(ip)) {
|
||||
LoginResponse rateResp = new LoginResponse(false, "Too many login attempts. Please wait 5 minutes.");
|
||||
return ResponseEntity.status(429).body(rateResp);
|
||||
}
|
||||
LoginResponse response = userService.verifyPinAndLogin(request.getMobileNumber(), request.getPin());
|
||||
if (response.isSuccess()) {
|
||||
Long userId = response.getUser() != null ? response.getUser().getId() : null;
|
||||
if (userId != null) {
|
||||
String token = jwtUtil.generateUserToken(userId, request.getMobileNumber());
|
||||
response.setToken(token);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout a user.
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ResponseEntity<LoginResponse> logout(@Valid @RequestBody LoginRequest request) {
|
||||
LoginResponse response = userService.logout(request.getMobileNumber());
|
||||
if (response.isSuccess()) {
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user PIN.
|
||||
* POST /api/auth/change-pin
|
||||
*/
|
||||
// ── AUTHENTICATED (customer token required) ────────────────────────────────
|
||||
|
||||
@PostMapping("/change-pin")
|
||||
public ResponseEntity<LoginResponse> changePin(@Valid @RequestBody ChangePinRequest request) {
|
||||
LoginResponse response = userService.changePin(request.getMobileNumber(), request.getCurrentPin(), request.getNewPin());
|
||||
if (response.isSuccess()) {
|
||||
return ResponseEntity.ok(response);
|
||||
} else {
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
// Extra ownership check: the JWT's mobileNumber must match the request
|
||||
String tokenMobile = getAuthenticatedMobile();
|
||||
if (tokenMobile != null && !tokenMobile.equals(request.getMobileNumber())) {
|
||||
return ResponseEntity.status(403).body(
|
||||
new LoginResponse(false, "You can only change your own PIN."));
|
||||
}
|
||||
LoginResponse response = userService.changePin(
|
||||
request.getMobileNumber(), request.getCurrentPin(), request.getNewPin());
|
||||
return response.isSuccess() ? ResponseEntity.ok(response) : ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user details by mobile number.
|
||||
* GET /api/auth/user/{mobileNumber}
|
||||
*/
|
||||
@GetMapping("/user/{mobileNumber}")
|
||||
public ResponseEntity<LoginResponse.UserDto> getUser(@PathVariable String mobileNumber) {
|
||||
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber);
|
||||
if (userDto != null) {
|
||||
return ResponseEntity.ok(userDto);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
// Customers can only fetch their own profile; staff can fetch any
|
||||
String tokenMobile = getAuthenticatedMobile();
|
||||
if (tokenMobile != null && !tokenMobile.equals(mobileNumber) && !isStaff()) {
|
||||
return ResponseEntity.status(403).build();
|
||||
}
|
||||
LoginResponse.UserDto userDto = userService.getUserByMobile(mobileNumber);
|
||||
return userDto != null ? ResponseEntity.ok(userDto) : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered users for administration dashboard.
|
||||
* GET /api/auth/users
|
||||
*/
|
||||
/**
|
||||
* Get all registered users for administration dashboard.
|
||||
* GET /api/auth/users
|
||||
*/
|
||||
// ── STAFF/MASTER ONLY ────────────────────────────────────────────────────────
|
||||
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<Page<LoginResponse.UserDto>> getAllUsers(
|
||||
@RequestParam(required = false) String search,
|
||||
@@ -115,41 +124,53 @@ public class UserController {
|
||||
return ResponseEntity.ok(users);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user details as an administrator.
|
||||
* PUT /api/auth/users/{id}
|
||||
*/
|
||||
@PutMapping("/users/{id}")
|
||||
public ResponseEntity<LoginResponse.UserDto> updateUser(@PathVariable Long id, @Valid @RequestBody UserUpdateRequest request) {
|
||||
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(), request.getMobileNumber(), request.getPin());
|
||||
if (updated != null) {
|
||||
return ResponseEntity.ok(updated);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
public ResponseEntity<LoginResponse.UserDto> updateUser(
|
||||
@PathVariable Long id,
|
||||
@Valid @RequestBody UserUpdateRequest request) {
|
||||
LoginResponse.UserDto updated = userService.updateUser(id, request.getName(),
|
||||
request.getMobileNumber(), request.getPin());
|
||||
return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user from the system.
|
||||
* DELETE /api/auth/users/{id}
|
||||
*/
|
||||
@DeleteMapping("/users/{id}")
|
||||
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
|
||||
userService.deleteUser(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user suspension status.
|
||||
* PATCH /api/auth/users/{id}/suspend
|
||||
*/
|
||||
@PatchMapping("/users/{id}/suspend")
|
||||
public ResponseEntity<LoginResponse.UserDto> toggleSuspension(@PathVariable Long id) {
|
||||
LoginResponse.UserDto updated = userService.toggleSuspension(id);
|
||||
if (updated != null) {
|
||||
return ResponseEntity.ok(updated);
|
||||
} else {
|
||||
return ResponseEntity.notFound().build();
|
||||
return updated != null ? ResponseEntity.ok(updated) : ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private String getAuthenticatedMobile() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getDetails() instanceof Claims claims) {
|
||||
// Customer tokens have the mobile as subject
|
||||
String type = (String) claims.get("type");
|
||||
if ("customer".equals(type)) {
|
||||
return claims.getSubject();
|
||||
}
|
||||
}
|
||||
return null; // Staff or system token — not a customer
|
||||
}
|
||||
|
||||
private boolean isStaff() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null) return false;
|
||||
return auth.getAuthorities().stream()
|
||||
.anyMatch(a -> a.getAuthority().startsWith("ROLE_MASTER")
|
||||
|| a.getAuthority().startsWith("ROLE_MANAGER")
|
||||
|| a.getAuthority().startsWith("ROLE_STAFF"));
|
||||
}
|
||||
|
||||
private String getClientIp(HttpServletRequest request) {
|
||||
String xfHeader = request.getHeader("X-Forwarded-For");
|
||||
if (xfHeader != null && !xfHeader.isEmpty()) return xfHeader.split(",")[0].trim();
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,11 @@ import com.rit.canteen.sales.model.TokenTransaction;
|
||||
import com.rit.canteen.sales.model.User;
|
||||
import com.rit.canteen.sales.service.TokenService;
|
||||
import com.rit.canteen.sales.service.UserService;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -18,17 +21,24 @@ import java.util.stream.Collectors;
|
||||
@RequestMapping("/api/wallet")
|
||||
public class WalletController {
|
||||
|
||||
private static final BigDecimal MAX_TOPUP = new BigDecimal("5000");
|
||||
|
||||
@Autowired
|
||||
private com.rit.canteen.sales.repository.UserRepository userRepository;
|
||||
|
||||
@Autowired
|
||||
private com.rit.canteen.sales.service.TokenService tokenService;
|
||||
|
||||
// ── CUSTOMER: own balance only ─────────────────────────────────────────
|
||||
|
||||
@GetMapping("/balance/{userId}")
|
||||
public ResponseEntity<?> getBalance(@PathVariable Long userId) {
|
||||
// Customers can only read their own balance
|
||||
if (!canAccessUser(userId)) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||
}
|
||||
try {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
|
||||
if (user == null) return ResponseEntity.notFound().build();
|
||||
return ResponseEntity.ok(Map.of("balance", user.getRitzTokenBalance()));
|
||||
} catch (Exception e) {
|
||||
@@ -36,6 +46,16 @@ public class WalletController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/transactions/{userId}")
|
||||
public ResponseEntity<?> getTransactions(@PathVariable Long userId) {
|
||||
if (!canAccessUser(userId)) {
|
||||
return ResponseEntity.status(403).body(Map.of("error", "Access denied"));
|
||||
}
|
||||
return ResponseEntity.ok(tokenService.getTransactions(userId));
|
||||
}
|
||||
|
||||
// ── STAFF/MASTER: wallet management ────────────────────────────────────
|
||||
|
||||
@GetMapping("/users")
|
||||
public ResponseEntity<List<Map<String, Object>>> getUsers() {
|
||||
List<User> users = userRepository.findAll();
|
||||
@@ -50,18 +70,22 @@ public class WalletController {
|
||||
return ResponseEntity.ok(userList);
|
||||
}
|
||||
|
||||
@GetMapping("/transactions/{userId}")
|
||||
public ResponseEntity<List<TokenTransaction>> getTransactions(@PathVariable Long userId) {
|
||||
return ResponseEntity.ok(tokenService.getTransactions(userId));
|
||||
}
|
||||
|
||||
@PostMapping("/topup")
|
||||
public ResponseEntity<?> topUp(@RequestBody Map<String, Object> request) {
|
||||
try {
|
||||
Long userId = Long.valueOf(request.get("userId").toString());
|
||||
BigDecimal amount = new BigDecimal(request.get("amount").toString());
|
||||
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
|
||||
|
||||
// ── FIX: validate amount BEFORE touching the database ──
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Amount must be positive"));
|
||||
}
|
||||
if (amount.compareTo(MAX_TOPUP) > 0) {
|
||||
return ResponseEntity.badRequest().body(
|
||||
Map.of("error", "Single transaction limit exceeded (Max: 5,000 Ritz Tokens)"));
|
||||
}
|
||||
|
||||
String ref = request.getOrDefault("referenceId", "TOPUP-" + System.currentTimeMillis()).toString();
|
||||
User updatedUser = tokenService.topUp(userId, amount, ref);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"success", true,
|
||||
@@ -89,4 +113,27 @@ public class WalletController {
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ResponseEntity.ok(tokenService.getAllCirculation(page, size));
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns true if the caller is a staff/manager/master, OR is the specific customer user.
|
||||
*/
|
||||
private boolean canAccessUser(Long targetUserId) {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || !auth.isAuthenticated()) return false;
|
||||
|
||||
if (auth.getDetails() instanceof Claims claims) {
|
||||
String role = (String) claims.get("role");
|
||||
// Staff roles can access anyone
|
||||
if ("MASTER".equals(role) || "MANAGER".equals(role) || "STAFF".equals(role)) return true;
|
||||
// Customers can only access themselves
|
||||
Object uid = claims.get("userId");
|
||||
if (uid != null) {
|
||||
Long tokenUserId = uid instanceof Integer ? ((Integer) uid).longValue() : (Long) uid;
|
||||
return tokenUserId.equals(targetUserId);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public class LoginResponse {
|
||||
private String message;
|
||||
private boolean userExists;
|
||||
private UserDto user;
|
||||
private String token; // JWT for customer session
|
||||
|
||||
public LoginResponse() {}
|
||||
|
||||
@@ -38,6 +39,9 @@ public class LoginResponse {
|
||||
public UserDto getUser() { return user; }
|
||||
public void setUser(UserDto user) { this.user = user; }
|
||||
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
|
||||
public static class UserDto {
|
||||
private Long id;
|
||||
private String mobileNumber;
|
||||
|
||||
@@ -40,7 +40,7 @@ public class SystemUserService {
|
||||
master.setPermissions(List.of("dashboard", "sale", "customers", "purchases", "inventory", "expense", "reports", "stores", "table", "wallet", "promotions", "feedback"));
|
||||
master.setViewOnly(false);
|
||||
repository.save(master);
|
||||
System.out.println(">>> SEEDED DEFAULT MASTER USER (Failsafe Source): " + masterUsername + " / " + masterPassword);
|
||||
System.out.println(">>> SEEDED DEFAULT MASTER USER from failsafe source.");
|
||||
} else {
|
||||
System.out.println(">>> MASTER USER(S) FOUND IN DATABASE. Skipping default seeding.");
|
||||
}
|
||||
|
||||
@@ -67,29 +67,32 @@ public class TokenService {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("User not found"));
|
||||
|
||||
int tokenCount = amount.intValue(); // Assuming 1 Ritz = 1 Serialized Unit
|
||||
// ── FIX: Validate BEFORE touching the database ──
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new RuntimeException("Amount must be positive");
|
||||
}
|
||||
if (amount.compareTo(new BigDecimal("5000")) > 0) {
|
||||
throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)");
|
||||
}
|
||||
|
||||
int tokenCount = amount.intValue();
|
||||
System.out.println("MINTING: Serializing " + tokenCount + " Ritz tokens for user " + userId);
|
||||
|
||||
|
||||
// High-Performance Batch Insertion using JDBC
|
||||
String sql = "INSERT INTO token_units (token_hash, owner_id, status, created_at) VALUES (?, ?, 'ACTIVE', ?)";
|
||||
List<Object[]> batchArgs = new ArrayList<>();
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
|
||||
for (int i = 0; i < tokenCount; i++) {
|
||||
String hash = generateSecureHash();
|
||||
batchArgs.add(new Object[]{hash, userId, now});
|
||||
}
|
||||
|
||||
|
||||
jdbcTemplate.batchUpdate(sql, batchArgs);
|
||||
|
||||
// Update cached balance
|
||||
BigDecimal currentBalance = user.getRitzTokenBalance() != null ? user.getRitzTokenBalance() : BigDecimal.ZERO;
|
||||
BigDecimal newBalance = currentBalance.add(amount);
|
||||
|
||||
if (amount.compareTo(new BigDecimal("5000")) > 0) {
|
||||
throw new RuntimeException("Single transaction limit exceeded (Max: 5,000 Ritz Tokens)");
|
||||
}
|
||||
|
||||
user.setRitzTokenBalance(newBalance);
|
||||
User savedUser = userRepository.save(user);
|
||||
|
||||
@@ -105,6 +108,7 @@ public class TokenService {
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
|
||||
@Transactional
|
||||
public void spend(Long userId, BigDecimal amount, String orderRef) {
|
||||
// High Concurrency Lock: Ensure no other thread modifies this user balance simultaneously
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
spring.application.name=backend
|
||||
server.address=0.0.0.0
|
||||
|
||||
spring.datasource.url=jdbc:postgresql://localhost:5432/positeasy
|
||||
spring.datasource.username=postgres
|
||||
spring.datasource.password=sidharth
|
||||
# ============================================================
|
||||
# DATABASE — use environment variables in production
|
||||
# Set DB_URL, DB_USER, DB_PASSWORD as env vars before running
|
||||
# ============================================================
|
||||
spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/positeasy}
|
||||
spring.datasource.username=${DB_USER:postgres}
|
||||
spring.datasource.password=${DB_PASSWORD:sidharth}
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.show-sql=true
|
||||
spring.jpa.show-sql=false
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||
spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Kolkata
|
||||
|
||||
# Master Account Credentials
|
||||
app.master.username=admin
|
||||
app.master.password=admin
|
||||
# ============================================================
|
||||
# JWT — CHANGE THIS SECRET IN PRODUCTION (min 256-bit key)
|
||||
# Set JWT_SECRET as an environment variable before running
|
||||
# ============================================================
|
||||
app.jwt.secret=${JWT_SECRET:3RiTCaNtEeNsUpErSeCrEtKeY2026!!xYzAbCdEfGhIjKlMn}
|
||||
app.jwt.expiration-ms=86400000
|
||||
|
||||
# ============================================================
|
||||
# Master Account — use environment variables in production
|
||||
# ============================================================
|
||||
app.master.username=${MASTER_USER:admin}
|
||||
app.master.password=${MASTER_PASSWORD:admin}
|
||||
|
||||
# File upload configuration
|
||||
spring.servlet.multipart.max-file-size=10MB
|
||||
spring.servlet.multipart.max-request-size=10MB
|
||||
|
||||
# ============================================================
|
||||
# CORS — comma-separated list of allowed frontend origins
|
||||
# Set APP_CORS_ORIGINS as env var in production
|
||||
# ============================================================
|
||||
app.cors.allowed-origins=${APP_CORS_ORIGINS:http://localhost:5173,http://localhost:5174,http://localhost:3000}
|
||||
|
||||
Reference in New Issue
Block a user