new admin-user api

This commit is contained in:
2025-12-08 07:10:10 -05:00
parent 0845443767
commit 7a8ec969b5
18 changed files with 1196 additions and 1004 deletions

View File

@@ -13,4 +13,6 @@ public interface UserRepository extends JpaRepository<User, Integer> {
boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email); boolean existsByEmailIgnoreCaseAndDeletedAtIsNull(String email);
Optional<User> findByUuid(UUID uuid); Optional<User> findByUuid(UUID uuid);
boolean existsByRole(String role);
} }

View File

@@ -1,16 +1,16 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
import group.goforward.ballistic.model.Brand; import group.goforward.ballistic.model.Brand;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface BrandService { public interface BrandService {
List<Brand> findAll(); List<Brand> findAll();
Optional<Brand> findById(Integer id); Optional<Brand> findById(Integer id);
Brand save(Brand item); Brand save(Brand item);
void deleteById(Integer id); void deleteById(Integer id);
} }

View File

@@ -1,95 +1,95 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
import group.goforward.ballistic.model.Merchant; import group.goforward.ballistic.model.Merchant;
import group.goforward.ballistic.model.MerchantCategoryMapping; import group.goforward.ballistic.model.MerchantCategoryMapping;
import group.goforward.ballistic.model.ProductConfiguration; import group.goforward.ballistic.model.ProductConfiguration;
import group.goforward.ballistic.repos.MerchantCategoryMappingRepository; import group.goforward.ballistic.repos.MerchantCategoryMappingRepository;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class MerchantCategoryMappingService { public class MerchantCategoryMappingService {
private final MerchantCategoryMappingRepository mappingRepository; private final MerchantCategoryMappingRepository mappingRepository;
public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) { public MerchantCategoryMappingService(MerchantCategoryMappingRepository mappingRepository) {
this.mappingRepository = mappingRepository; this.mappingRepository = mappingRepository;
} }
public List<MerchantCategoryMapping> findByMerchant(Integer merchantId) { public List<MerchantCategoryMapping> findByMerchant(Integer merchantId) {
return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId); return mappingRepository.findByMerchantIdOrderByRawCategoryAsc(merchantId);
} }
/** /**
* Resolve (or create) a mapping row for this merchant + raw category. * Resolve (or create) a mapping row for this merchant + raw category.
* - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set). * - If it exists, returns it (with whatever mappedPartRole / mappedConfiguration are set).
* - If it doesn't exist, creates a placeholder row with null mappings and returns it. * - If it doesn't exist, creates a placeholder row with null mappings and returns it.
* *
* The importer can then: * The importer can then:
* - skip rows where mappedPartRole is still null * - skip rows where mappedPartRole is still null
* - use mappedConfiguration if present * - use mappedConfiguration if present
*/ */
@Transactional @Transactional
public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) { public MerchantCategoryMapping resolveMapping(Merchant merchant, String rawCategory) {
if (rawCategory == null || rawCategory.isBlank()) { if (rawCategory == null || rawCategory.isBlank()) {
return null; return null;
} }
String trimmed = rawCategory.trim(); String trimmed = rawCategory.trim();
return mappingRepository return mappingRepository
.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
.orElseGet(() -> { .orElseGet(() -> {
MerchantCategoryMapping mapping = new MerchantCategoryMapping(); MerchantCategoryMapping mapping = new MerchantCategoryMapping();
mapping.setMerchant(merchant); mapping.setMerchant(merchant);
mapping.setRawCategory(trimmed); mapping.setRawCategory(trimmed);
mapping.setMappedPartRole(null); mapping.setMappedPartRole(null);
mapping.setMappedConfiguration(null); mapping.setMappedConfiguration(null);
return mappingRepository.save(mapping); return mappingRepository.save(mapping);
}); });
} }
/** /**
* Upsert mapping (admin UI). * Upsert mapping (admin UI).
*/ */
@Transactional @Transactional
public MerchantCategoryMapping upsertMapping( public MerchantCategoryMapping upsertMapping(
Merchant merchant, Merchant merchant,
String rawCategory, String rawCategory,
String mappedPartRole, String mappedPartRole,
ProductConfiguration mappedConfiguration ProductConfiguration mappedConfiguration
) { ) {
String trimmed = rawCategory.trim(); String trimmed = rawCategory.trim();
MerchantCategoryMapping mapping = mappingRepository MerchantCategoryMapping mapping = mappingRepository
.findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed) .findByMerchantIdAndRawCategoryIgnoreCase(merchant.getId(), trimmed)
.orElseGet(() -> { .orElseGet(() -> {
MerchantCategoryMapping m = new MerchantCategoryMapping(); MerchantCategoryMapping m = new MerchantCategoryMapping();
m.setMerchant(merchant); m.setMerchant(merchant);
m.setRawCategory(trimmed); m.setRawCategory(trimmed);
return m; return m;
}); });
mapping.setMappedPartRole( mapping.setMappedPartRole(
(mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim() (mappedPartRole == null || mappedPartRole.isBlank()) ? null : mappedPartRole.trim()
); );
mapping.setMappedConfiguration(mappedConfiguration); mapping.setMappedConfiguration(mappedConfiguration);
return mappingRepository.save(mapping); return mappingRepository.save(mapping);
} }
/** /**
* Backwards-compatible overload for existing callers (e.g. controller) * Backwards-compatible overload for existing callers (e.g. controller)
* that dont care about productConfiguration yet. * that dont care about productConfiguration yet.
*/ */
@Transactional @Transactional
public MerchantCategoryMapping upsertMapping( public MerchantCategoryMapping upsertMapping(
Merchant merchant, Merchant merchant,
String rawCategory, String rawCategory,
String mappedPartRole String mappedPartRole
) { ) {
// Delegate to the new method with `null` configuration // Delegate to the new method with `null` configuration
return upsertMapping(merchant, rawCategory, mappedPartRole, null); return upsertMapping(merchant, rawCategory, mappedPartRole, null);
} }
} }

View File

@@ -1,14 +1,14 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
public interface MerchantFeedImportService { public interface MerchantFeedImportService {
/** /**
* Full product + offer import for a given merchant. * Full product + offer import for a given merchant.
*/ */
void importMerchantFeed(Integer merchantId); void importMerchantFeed(Integer merchantId);
/** /**
* Offers-only sync (price / stock) for a given merchant. * Offers-only sync (price / stock) for a given merchant.
*/ */
void syncOffersOnly(Integer merchantId); void syncOffersOnly(Integer merchantId);
} }

View File

@@ -1,17 +1,17 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
import group.goforward.ballistic.model.Psa; import group.goforward.ballistic.model.Psa;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
public interface PsaService { public interface PsaService {
List<Psa> findAll(); List<Psa> findAll();
Optional<Psa> findById(UUID id); Optional<Psa> findById(UUID id);
Psa save(Psa psa); Psa save(Psa psa);
void deleteById(UUID id); void deleteById(UUID id);
} }

View File

@@ -1,16 +1,16 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
import group.goforward.ballistic.model.State; import group.goforward.ballistic.model.State;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface StatesService { public interface StatesService {
List<State> findAll(); List<State> findAll();
Optional<State> findById(Integer id); Optional<State> findById(Integer id);
State save(State item); State save(State item);
void deleteById(Integer id); void deleteById(Integer id);
} }

View File

@@ -1,16 +1,16 @@
package group.goforward.ballistic.services; package group.goforward.ballistic.services;
import group.goforward.ballistic.model.User; import group.goforward.ballistic.model.User;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
public interface UsersService { public interface UsersService {
List<User> findAll(); List<User> findAll();
Optional<User> findById(Integer id); Optional<User> findById(Integer id);
User save(User item); User save(User item);
void deleteById(Integer id); void deleteById(Integer id);
} }

View File

@@ -0,0 +1,55 @@
package group.goforward.ballistic.services.admin;
import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository;
import group.goforward.ballistic.web.dto.admin.AdminUserDto;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Set;
import java.util.UUID;
@Service
public class AdminUserService {
private static final Set<String> ALLOWED_ROLES = Set.of("USER", "ADMIN");
private final UserRepository userRepository;
public AdminUserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public List<AdminUserDto> getAllUsersForAdmin() {
return userRepository.findAll()
.stream()
.map(AdminUserDto::fromUser)
.toList();
}
@Transactional
public AdminUserDto updateUserRole(UUID userUuid, String newRole, Authentication auth) {
if (newRole == null || !ALLOWED_ROLES.contains(newRole.toUpperCase())) {
throw new IllegalArgumentException("Invalid role: " + newRole);
}
User user = userRepository.findByUuid(userUuid)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
// Optional safety: do not allow demoting yourself (you can loosen this later)
String currentEmail = auth != null ? auth.getName() : null;
boolean isSelf = currentEmail != null
&& currentEmail.equalsIgnoreCase(user.getEmail());
if (isSelf && !"ADMIN".equalsIgnoreCase(newRole)) {
throw new IllegalStateException("You cannot change your own role to non-admin.");
}
user.setRole(newRole.toUpperCase());
// updatedAt will be handled by your entity / DB defaults
return AdminUserDto.fromUser(user);
}
}

View File

@@ -1,38 +1,38 @@
package group.goforward.ballistic.services.impl; package group.goforward.ballistic.services.impl;
import group.goforward.ballistic.model.Brand; import group.goforward.ballistic.model.Brand;
import group.goforward.ballistic.repos.BrandRepository; import group.goforward.ballistic.repos.BrandRepository;
import group.goforward.ballistic.services.BrandService; import group.goforward.ballistic.services.BrandService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class BrandServiceImpl implements BrandService { public class BrandServiceImpl implements BrandService {
@Autowired @Autowired
private BrandRepository repo; private BrandRepository repo;
@Override @Override
public List<Brand> findAll() { public List<Brand> findAll() {
return repo.findAll(); return repo.findAll();
} }
@Override @Override
public Optional<Brand> findById(Integer id) { public Optional<Brand> findById(Integer id) {
return repo.findById(id); return repo.findById(id);
} }
@Override @Override
public Brand save(Brand item) { public Brand save(Brand item) {
return null; return null;
} }
@Override @Override
public void deleteById(Integer id) { public void deleteById(Integer id) {
deleteById(id); deleteById(id);
} }
} }

View File

@@ -1,41 +1,41 @@
package group.goforward.ballistic.services.impl; package group.goforward.ballistic.services.impl;
import group.goforward.ballistic.model.Psa; import group.goforward.ballistic.model.Psa;
import group.goforward.ballistic.repos.PsaRepository; import group.goforward.ballistic.repos.PsaRepository;
import group.goforward.ballistic.services.PsaService; import group.goforward.ballistic.services.PsaService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Service @Service
public class PsaServiceImpl implements PsaService { public class PsaServiceImpl implements PsaService {
private final PsaRepository psaRepository; private final PsaRepository psaRepository;
@Autowired @Autowired
public PsaServiceImpl(PsaRepository psaRepository) { public PsaServiceImpl(PsaRepository psaRepository) {
this.psaRepository = psaRepository; this.psaRepository = psaRepository;
} }
@Override @Override
public List<Psa> findAll() { public List<Psa> findAll() {
return psaRepository.findAll(); return psaRepository.findAll();
} }
@Override @Override
public Optional<Psa> findById(UUID id) { public Optional<Psa> findById(UUID id) {
return psaRepository.findById(id); return psaRepository.findById(id);
} }
@Override @Override
public Psa save(Psa psa) { public Psa save(Psa psa) {
return psaRepository.save(psa); return psaRepository.save(psa);
} }
@Override @Override
public void deleteById(UUID id) { public void deleteById(UUID id) {
psaRepository.deleteById(id); psaRepository.deleteById(id);
} }
} }

View File

@@ -1,38 +1,38 @@
package group.goforward.ballistic.services.impl; package group.goforward.ballistic.services.impl;
import group.goforward.ballistic.model.State; import group.goforward.ballistic.model.State;
import group.goforward.ballistic.repos.StateRepository; import group.goforward.ballistic.repos.StateRepository;
import group.goforward.ballistic.services.StatesService; import group.goforward.ballistic.services.StatesService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class StatesServiceImpl implements StatesService { public class StatesServiceImpl implements StatesService {
@Autowired @Autowired
private StateRepository repo; private StateRepository repo;
@Override @Override
public List<State> findAll() { public List<State> findAll() {
return repo.findAll(); return repo.findAll();
} }
@Override @Override
public Optional<State> findById(Integer id) { public Optional<State> findById(Integer id) {
return repo.findById(id); return repo.findById(id);
} }
@Override @Override
public State save(State item) { public State save(State item) {
return null; return null;
} }
@Override @Override
public void deleteById(Integer id) { public void deleteById(Integer id) {
deleteById(id); deleteById(id);
} }
} }

View File

@@ -1,37 +1,37 @@
package group.goforward.ballistic.services.impl; package group.goforward.ballistic.services.impl;
import group.goforward.ballistic.model.User; import group.goforward.ballistic.model.User;
import group.goforward.ballistic.repos.UserRepository; import group.goforward.ballistic.repos.UserRepository;
import group.goforward.ballistic.services.UsersService; import group.goforward.ballistic.services.UsersService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@Service @Service
public class UsersServiceImpl implements UsersService { public class UsersServiceImpl implements UsersService {
@Autowired @Autowired
private UserRepository repo; private UserRepository repo;
@Override @Override
public List<User> findAll() { public List<User> findAll() {
return repo.findAll(); return repo.findAll();
} }
@Override @Override
public Optional<User> findById(Integer id) { public Optional<User> findById(Integer id) {
return repo.findById(id); return repo.findById(id);
} }
@Override @Override
public User save(User item) { public User save(User item) {
return null; return null;
} }
@Override @Override
public void deleteById(Integer id) { public void deleteById(Integer id) {
deleteById(id); deleteById(id);
} }
} }

View File

@@ -1,13 +1,13 @@
/** /**
* Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application. * Provides the classes necessary for the Spring Services implementations for the ballistic -Builder application.
* This package includes Services implementations for Spring-Boot application * This package includes Services implementations for Spring-Boot application
* *
* *
* <p>The main entry point for managing the inventory is the * <p>The main entry point for managing the inventory is the
* {@link group.goforward.ballistic.BattlBuilderApplication} class.</p> * {@link group.goforward.ballistic.BattlBuilderApplication} class.</p>
* *
* @since 1.0 * @since 1.0
* @author Don Strawsburg * @author Don Strawsburg
* @version 1.1 * @version 1.1
*/ */
package group.goforward.ballistic.services.impl; package group.goforward.ballistic.services.impl;

View File

@@ -0,0 +1,37 @@
package group.goforward.ballistic.web.admin;
import group.goforward.ballistic.services.admin.AdminUserService;
import group.goforward.ballistic.web.dto.admin.AdminUserDto;
import group.goforward.ballistic.web.dto.admin.UpdateUserRoleRequest;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public class AdminUserController {
private final AdminUserService adminUserService;
public AdminUserController(AdminUserService adminUserService) {
this.adminUserService = adminUserService;
}
@GetMapping
public List<AdminUserDto> listUsers() {
return adminUserService.getAllUsersForAdmin();
}
@PatchMapping("/{uuid}/role")
public AdminUserDto updateRole(
@PathVariable("uuid") UUID uuid,
@RequestBody UpdateUserRoleRequest request,
Authentication auth
) {
return adminUserService.updateUserRole(uuid, request.getRole(), auth);
}
}

View File

@@ -0,0 +1,76 @@
package group.goforward.ballistic.web.dto.admin;
import group.goforward.ballistic.model.User;
import java.time.OffsetDateTime;
import java.util.UUID;
public class AdminUserDto {
// We'll expose the UUID as the "id" used by the frontend
private UUID id;
private String email;
private String displayName;
private String role;
private OffsetDateTime createdAt;
private OffsetDateTime updatedAt;
private OffsetDateTime lastLoginAt;
public AdminUserDto(UUID id,
String email,
String displayName,
String role,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
OffsetDateTime lastLoginAt) {
this.id = id;
this.email = email;
this.displayName = displayName;
this.role = role;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.lastLoginAt = lastLoginAt;
}
public static AdminUserDto fromUser(User user) {
return new AdminUserDto(
user.getUuid(), // use UUID here (stable id)
user.getEmail(),
user.getDisplayName(),
user.getRole(), // String: "USER" / "ADMIN"
user.getCreatedAt(),
user.getUpdatedAt(),
user.getLastLoginAt()
);
}
// Getters (and setters if you want Jackson to use them)
public UUID getId() {
return id;
}
public String getEmail() {
return email;
}
public String getDisplayName() {
return displayName;
}
public String getRole() {
return role;
}
public OffsetDateTime getCreatedAt() {
return createdAt;
}
public OffsetDateTime getUpdatedAt() {
return updatedAt;
}
public OffsetDateTime getLastLoginAt() {
return lastLoginAt;
}
}

View File

@@ -0,0 +1,21 @@
package group.goforward.ballistic.web.dto.admin;
public class UpdateUserRoleRequest {
private String role;
public UpdateUserRoleRequest() {
}
public UpdateUserRoleRequest(String role) {
this.role = role;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}

View File

@@ -13,9 +13,10 @@ spring.datasource.driver-class-name=org.postgresql.Driver
security.jwt.secret=ballistic-test-secret-key-1234567890-ABCDEFGHIJKLNMOPQRST security.jwt.secret=ballistic-test-secret-key-1234567890-ABCDEFGHIJKLNMOPQRST
security.jwt.access-token-minutes=2880 security.jwt.access-token-minutes=2880
# Temp disabling logging to find what I fucked up # Logging
spring.jpa.show-sql=false
logging.level.org.hibernate.SQL=warn spring.jpa.show-sql=true
logging.level.org.hibernate.SQL=INFO
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=warn logging.level.org.hibernate.type.descriptor.sql.BasicBinder=warn