Initial commit
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
package pl.polskalokalnie;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
||||
|
||||
// Uwierzytelnianie realizuje wlasny filtr JWT, wiec wylaczamy domyslnego
|
||||
// uzytkownika Spring Security (i mylacy log "Using generated security password").
|
||||
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
|
||||
public class PolskaLokalnieApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(PolskaLokalnieApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package pl.polskalokalnie.admin;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record AddForbiddenWordRequest(
|
||||
@NotBlank(message = "Słowo nie może być puste")
|
||||
@Size(max = 120, message = "Słowo jest zbyt długie")
|
||||
String word
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package pl.polskalokalnie.admin;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.moderation.ForbiddenWordResponse;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||
import pl.polskalokalnie.listing.ListingResponse;
|
||||
import pl.polskalokalnie.listing.ListingService;
|
||||
import pl.polskalokalnie.listing.ListingStatus;
|
||||
import pl.polskalokalnie.message.MessageResponse;
|
||||
import pl.polskalokalnie.message.MessageService;
|
||||
import pl.polskalokalnie.message.SendMessageRequest;
|
||||
import pl.polskalokalnie.report.ListingReportResponse;
|
||||
import pl.polskalokalnie.report.ListingReportService;
|
||||
import pl.polskalokalnie.report.ResolveListingReportRequest;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.BlockedEmail;
|
||||
import pl.polskalokalnie.user.BlockedEmailRepository;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
public class AdminController {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BlockedEmailRepository blockedEmailRepository;
|
||||
private final ListingService listingService;
|
||||
private final ListingReportService listingReportService;
|
||||
private final MessageService messageService;
|
||||
private final TextModerationService textModerationService;
|
||||
|
||||
public AdminController(UserRepository userRepository, BlockedEmailRepository blockedEmailRepository,
|
||||
ListingService listingService, ListingReportService listingReportService,
|
||||
MessageService messageService,
|
||||
TextModerationService textModerationService) {
|
||||
this.userRepository = userRepository;
|
||||
this.blockedEmailRepository = blockedEmailRepository;
|
||||
this.listingService = listingService;
|
||||
this.listingReportService = listingReportService;
|
||||
this.messageService = messageService;
|
||||
this.textModerationService = textModerationService;
|
||||
}
|
||||
|
||||
// --- Uzytkownicy ---
|
||||
|
||||
@GetMapping("/users")
|
||||
public List<UserResponse> users() {
|
||||
return userRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(AppUser::getCreatedAt).reversed())
|
||||
.map(UserResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/block")
|
||||
public UserResponse block(@PathVariable Long id) {
|
||||
return setBlocked(id, true);
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/unblock")
|
||||
public UserResponse unblock(@PathVariable Long id) {
|
||||
return setBlocked(id, false);
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/verify")
|
||||
public UserResponse verify(@PathVariable Long id) {
|
||||
AppUser user = requireUser(id);
|
||||
user.setVerified(true);
|
||||
return UserResponse.from(userRepository.save(user));
|
||||
}
|
||||
|
||||
@DeleteMapping("/users/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteUser(@PathVariable Long id) {
|
||||
AppUser user = requireUser(id);
|
||||
if (user.getRole() == Role.ADMIN) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można usunąć konta administratora");
|
||||
}
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
// Odrzucenie konta podczas weryfikacji: e-mail trafia na trwala liste zablokowanych
|
||||
// adresow, wiec nie da sie nim ponownie zalozyc konta.
|
||||
@PostMapping("/users/{id}/reject")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void rejectUser(@PathVariable Long id) {
|
||||
AppUser user = requireUser(id);
|
||||
if (user.getRole() == Role.ADMIN) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można odrzucić konta administratora");
|
||||
}
|
||||
if (!blockedEmailRepository.existsByEmailIgnoreCase(user.getEmail())) {
|
||||
BlockedEmail blockedEmail = new BlockedEmail();
|
||||
blockedEmail.setEmail(user.getEmail());
|
||||
blockedEmailRepository.save(blockedEmail);
|
||||
}
|
||||
userRepository.delete(user);
|
||||
}
|
||||
|
||||
private UserResponse setBlocked(Long id, boolean blocked) {
|
||||
AppUser user = requireUser(id);
|
||||
if (user.getRole() == Role.ADMIN) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Nie można zablokować konta administratora");
|
||||
}
|
||||
user.setBlocked(blocked);
|
||||
return UserResponse.from(userRepository.save(user));
|
||||
}
|
||||
|
||||
private AppUser requireUser(Long id) {
|
||||
return userRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Użytkownik nie istnieje"));
|
||||
}
|
||||
|
||||
// --- Wiadomosci do uzytkownika ---
|
||||
|
||||
@GetMapping("/users/{id}/messages")
|
||||
public List<MessageResponse> userMessages(@PathVariable Long id, Authentication authentication) {
|
||||
AppUser user = requireUser(id);
|
||||
AppUser admin = requireAdmin(authentication);
|
||||
return messageService.getConversation(admin.getId(), user.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/users/{id}/messages")
|
||||
public MessageResponse sendMessageToUser(@PathVariable Long id, Authentication authentication,
|
||||
@Valid @RequestBody SendMessageRequest request) {
|
||||
AppUser user = requireUser(id);
|
||||
AppUser admin = requireAdmin(authentication);
|
||||
return messageService.send(admin.getId(), user.getId(), request.content());
|
||||
}
|
||||
|
||||
private AppUser requireAdmin(Authentication authentication) {
|
||||
return userRepository.findByEmailIgnoreCase(authentication.getName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
||||
}
|
||||
|
||||
// --- Moderacja ogloszen ---
|
||||
|
||||
@GetMapping("/listings")
|
||||
public List<ListingResponse> listings() {
|
||||
return listingService.findAllForModeration();
|
||||
}
|
||||
|
||||
@PostMapping("/listings/{id}/approve")
|
||||
public ListingResponse approve(@PathVariable Long id) {
|
||||
return listingService.changeStatus(id, ListingStatus.APPROVED);
|
||||
}
|
||||
|
||||
@PostMapping("/listings/{id}/reject")
|
||||
public ListingResponse reject(@PathVariable Long id) {
|
||||
return listingService.changeStatus(id, ListingStatus.REJECTED);
|
||||
}
|
||||
|
||||
@DeleteMapping("/listings/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteListing(@PathVariable Long id) {
|
||||
listingService.delete(id);
|
||||
}
|
||||
|
||||
@GetMapping("/reports")
|
||||
public List<ListingReportResponse> reports() {
|
||||
return listingReportService.findAllForAdmin();
|
||||
}
|
||||
|
||||
@PostMapping("/reports/{id}/resolve")
|
||||
public ListingReportResponse resolveReport(
|
||||
@PathVariable Long id,
|
||||
Authentication authentication,
|
||||
@RequestBody(required = false) ResolveListingReportRequest request
|
||||
) {
|
||||
return listingReportService.resolve(id, authentication.getName(), request == null ? null : request.note());
|
||||
}
|
||||
|
||||
@PostMapping("/reports/{id}/delete-listing")
|
||||
public ListingReportResponse deleteListingFromReport(@PathVariable Long id, Authentication authentication) {
|
||||
return listingReportService.deleteListingAndResolve(id, authentication.getName());
|
||||
}
|
||||
|
||||
// --- Slowa zabronione ---
|
||||
|
||||
@GetMapping("/forbidden-words")
|
||||
public List<ForbiddenWordResponse> forbiddenWords() {
|
||||
return textModerationService.listForbiddenWords();
|
||||
}
|
||||
|
||||
@PostMapping("/forbidden-words")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public List<ForbiddenWordResponse> addForbiddenWord(@Valid @RequestBody AddForbiddenWordRequest request) {
|
||||
return textModerationService.addForbiddenWord(request.word());
|
||||
}
|
||||
|
||||
@DeleteMapping("/forbidden-words/{id}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void deleteForbiddenWord(@PathVariable Long id) {
|
||||
textModerationService.removeForbiddenWord(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
|
||||
public AuthController(AuthService authService) {
|
||||
this.authService = authService;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(authService.register(request));
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public AuthResponse login(@Valid @RequestBody LoginRequest request) {
|
||||
return authService.login(request);
|
||||
}
|
||||
|
||||
@PostMapping("/social")
|
||||
public AuthResponse social(@Valid @RequestBody SocialLoginRequest request) {
|
||||
return authService.socialLogin(request);
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public UserResponse me(Authentication authentication) {
|
||||
return authService.currentUser(authentication.getName());
|
||||
}
|
||||
|
||||
@PutMapping("/me")
|
||||
public UserResponse updateMe(Authentication authentication, @Valid @RequestBody UpdateProfileRequest request) {
|
||||
return authService.updateProfile(authentication.getName(), request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Locale;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.auth.dto.AuthResponse;
|
||||
import pl.polskalokalnie.auth.dto.LoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.RegisterRequest;
|
||||
import pl.polskalokalnie.auth.dto.SocialLoginRequest;
|
||||
import pl.polskalokalnie.auth.dto.UpdateProfileRequest;
|
||||
import pl.polskalokalnie.auth.dto.UserResponse;
|
||||
import pl.polskalokalnie.user.AccountType;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.AuthProvider;
|
||||
import pl.polskalokalnie.user.BlockedEmailRepository;
|
||||
import pl.polskalokalnie.user.ContactPreference;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@Service
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final BlockedEmailRepository blockedEmailRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final TextModerationService textModerationService;
|
||||
|
||||
public AuthService(
|
||||
UserRepository userRepository,
|
||||
BlockedEmailRepository blockedEmailRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JwtService jwtService,
|
||||
TextModerationService textModerationService
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.blockedEmailRepository = blockedEmailRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.jwtService = jwtService;
|
||||
this.textModerationService = textModerationService;
|
||||
}
|
||||
|
||||
public AuthResponse register(RegisterRequest request) {
|
||||
String email = normalizeEmail(request.email());
|
||||
if (blockedEmailRepository.existsByEmailIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji");
|
||||
}
|
||||
if (userRepository.existsByEmailIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "Konto z tym adresem e-mail już istnieje");
|
||||
}
|
||||
|
||||
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.nip());
|
||||
|
||||
AppUser user = new AppUser();
|
||||
user.setEmail(email);
|
||||
user.setFullName(request.fullName().trim());
|
||||
user.setPasswordHash(passwordEncoder.encode(request.password()));
|
||||
user.setRole(Role.USER);
|
||||
user.setProvider(AuthProvider.LOCAL);
|
||||
user.setAccountType(request.accountType() != null ? request.accountType() : AccountType.PERSONAL);
|
||||
user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null);
|
||||
user.setNip(request.nip() != null && !request.nip().isBlank() ? request.nip().trim() : null);
|
||||
// Konta zalozone samodzielnie czekaja na weryfikacje danych przez administratora.
|
||||
user.setVerified(false);
|
||||
|
||||
return buildAuthResponse(userRepository.save(user));
|
||||
}
|
||||
|
||||
public AuthResponse login(LoginRequest request) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(normalizeEmail(request.email()))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Nieprawidłowy e-mail lub hasło"));
|
||||
|
||||
if (user.getPasswordHash() == null
|
||||
|| !passwordEncoder.matches(request.password(), user.getPasswordHash())) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Nieprawidłowy e-mail lub hasło");
|
||||
}
|
||||
if (user.isBlocked()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane");
|
||||
}
|
||||
|
||||
return buildAuthResponse(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Symulowane logowanie spoleczne: znajduje lub tworzy konto powiazane z dostawca.
|
||||
* Nie ma tu prawdziwego OAuth - dane przychodza z frontendu jako demo.
|
||||
*/
|
||||
public AuthResponse socialLogin(SocialLoginRequest request) {
|
||||
AuthProvider provider = request.provider();
|
||||
String email = normalizeEmail(
|
||||
request.email() != null && !request.email().isBlank()
|
||||
? request.email()
|
||||
: defaultSocialEmail(provider));
|
||||
String name = request.fullName() != null && !request.fullName().isBlank()
|
||||
? request.fullName().trim()
|
||||
: defaultSocialName(provider);
|
||||
|
||||
textModerationService.validateOrThrow(name);
|
||||
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email).orElseGet(() -> {
|
||||
if (blockedEmailRepository.existsByEmailIgnoreCase(email)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Ten adres e-mail został zablokowany i nie można go już użyć do rejestracji");
|
||||
}
|
||||
AppUser created = new AppUser();
|
||||
created.setEmail(email);
|
||||
created.setFullName(name);
|
||||
created.setRole(Role.USER);
|
||||
created.setProvider(provider);
|
||||
// Logowanie spoleczne nie wymaga rekopisania danych, wiec konto jest od razu zweryfikowane.
|
||||
created.setVerified(true);
|
||||
return userRepository.save(created);
|
||||
});
|
||||
|
||||
if (user.isBlocked()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Konto zostało zablokowane");
|
||||
}
|
||||
|
||||
return buildAuthResponse(user);
|
||||
}
|
||||
|
||||
public UserResponse currentUser(String email) {
|
||||
return userRepository.findByEmailIgnoreCase(email)
|
||||
.map(UserResponse::from)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
}
|
||||
|
||||
public UserResponse updateProfile(String email, UpdateProfileRequest request) {
|
||||
AppUser user = userRepository.findByEmailIgnoreCase(email)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Sesja wygasła"));
|
||||
|
||||
textModerationService.validateOrThrow(request.fullName(), request.phone(), request.birthDate(), request.address());
|
||||
|
||||
user.setFullName(request.fullName().trim());
|
||||
user.setPhone(request.phone() != null && !request.phone().isBlank() ? request.phone().trim() : null);
|
||||
user.setAddress(request.address() != null && !request.address().isBlank() ? request.address().trim() : null);
|
||||
user.setContactPreference(request.contactPreference() != null ? request.contactPreference() : ContactPreference.EMAIL_AND_PHONE);
|
||||
user.setPreferredLanguage(request.preferredLanguage() != null ? request.preferredLanguage() : PreferredLanguage.PL);
|
||||
if (request.birthDate() != null && !request.birthDate().isBlank()) {
|
||||
try {
|
||||
user.setBirthDate(LocalDate.parse(request.birthDate().trim()));
|
||||
} catch (DateTimeParseException ex) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowy format daty urodzenia");
|
||||
}
|
||||
} else {
|
||||
user.setBirthDate(null);
|
||||
}
|
||||
|
||||
return UserResponse.from(userRepository.save(user));
|
||||
}
|
||||
|
||||
private AuthResponse buildAuthResponse(AppUser user) {
|
||||
return new AuthResponse(jwtService.generateToken(user), UserResponse.from(user));
|
||||
}
|
||||
|
||||
private String normalizeEmail(String email) {
|
||||
return email.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String defaultSocialEmail(AuthProvider provider) {
|
||||
return switch (provider) {
|
||||
case GOOGLE -> "demo.google@gmail.com";
|
||||
case FACEBOOK -> "demo.facebook@facebook.com";
|
||||
case LOCAL -> "demo.local@mieszko.pl";
|
||||
};
|
||||
}
|
||||
|
||||
private String defaultSocialName(AuthProvider provider) {
|
||||
return switch (provider) {
|
||||
case GOOGLE -> "Użytkownik Google";
|
||||
case FACEBOOK -> "Użytkownik Facebook";
|
||||
case LOCAL -> "Użytkownik";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
@Component
|
||||
public class JwtAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
|
||||
public JwtAuthFilter(JwtService jwtService) {
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
try {
|
||||
Claims claims = jwtService.parse(token);
|
||||
String email = claims.getSubject();
|
||||
String role = claims.get("role", String.class);
|
||||
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
|
||||
var authentication = new UsernamePasswordAuthenticationToken(email, null, authorities);
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
} catch (Exception ex) {
|
||||
// Nieprawidlowy lub wygasly token: zadanie leci dalej jako nieuwierzytelnione.
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import javax.crypto.SecretKey;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
|
||||
@Service
|
||||
public class JwtService {
|
||||
|
||||
private final SecretKey key;
|
||||
private final long expirationSeconds;
|
||||
|
||||
public JwtService(
|
||||
@Value("${app.jwt.secret}") String secret,
|
||||
@Value("${app.jwt.expiration-seconds:86400}") long expirationSeconds
|
||||
) {
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.expirationSeconds = expirationSeconds;
|
||||
}
|
||||
|
||||
public String generateToken(AppUser user) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.subject(user.getEmail())
|
||||
.claim("role", user.getRole().name())
|
||||
.claim("name", user.getFullName())
|
||||
.claim("uid", user.getId())
|
||||
.issuedAt(Date.from(now))
|
||||
.expiration(Date.from(now.plusSeconds(expirationSeconds)))
|
||||
.signWith(key)
|
||||
.compact();
|
||||
}
|
||||
|
||||
public Claims parse(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package pl.polskalokalnie.auth;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
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.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
private final JwtAuthFilter jwtAuthFilter;
|
||||
|
||||
public SecurityConfig(JwtAuthFilter jwtAuthFilter) {
|
||||
this.jwtAuthFilter = jwtAuthFilter;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.cors(Customizer.withDefaults())
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||
.requestMatchers("/error").permitAll()
|
||||
.requestMatchers("/api/auth/register", "/api/auth/login", "/api/auth/social").permitAll()
|
||||
.requestMatchers("/api/i18n/translate").permitAll()
|
||||
.requestMatchers("/api/auth/me").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
|
||||
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
|
||||
.requestMatchers("/api/admin/**").hasRole("ADMIN")
|
||||
.requestMatchers(HttpMethod.POST, "/api/listings").authenticated()
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
public record AuthResponse(
|
||||
String token,
|
||||
UserResponse user
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import pl.polskalokalnie.user.AccountType;
|
||||
|
||||
public record RegisterRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(min = 6, max = 72) String password,
|
||||
@NotBlank String fullName,
|
||||
AccountType accountType,
|
||||
String phone,
|
||||
String nip
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import pl.polskalokalnie.user.AuthProvider;
|
||||
|
||||
/**
|
||||
* Symulowane logowanie spoleczne. W wersji produkcyjnej email/name pochodzilyby
|
||||
* z tokenu dostawcy OAuth (Google/Facebook); tutaj przychodza z frontendu jako dane demo.
|
||||
*/
|
||||
public record SocialLoginRequest(
|
||||
@NotNull AuthProvider provider,
|
||||
String email,
|
||||
String fullName
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import pl.polskalokalnie.user.ContactPreference;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
|
||||
public record UpdateProfileRequest(
|
||||
@NotBlank String fullName,
|
||||
String phone,
|
||||
String birthDate,
|
||||
String address,
|
||||
ContactPreference contactPreference,
|
||||
PreferredLanguage preferredLanguage
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package pl.polskalokalnie.auth.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import pl.polskalokalnie.user.AccountType;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.AuthProvider;
|
||||
import pl.polskalokalnie.user.ContactPreference;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
|
||||
public record UserResponse(
|
||||
Long id,
|
||||
String email,
|
||||
String fullName,
|
||||
Role role,
|
||||
AuthProvider provider,
|
||||
AccountType accountType,
|
||||
String phone,
|
||||
String address,
|
||||
ContactPreference contactPreference,
|
||||
PreferredLanguage preferredLanguage,
|
||||
String nip,
|
||||
LocalDate birthDate,
|
||||
boolean verified,
|
||||
boolean blocked,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static UserResponse from(AppUser user) {
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getFullName(),
|
||||
user.getRole(),
|
||||
user.getProvider(),
|
||||
user.getAccountType(),
|
||||
user.getPhone(),
|
||||
user.getAddress(),
|
||||
user.getContactPreference() != null ? user.getContactPreference() : ContactPreference.EMAIL_AND_PHONE,
|
||||
user.getPreferredLanguage() != null ? user.getPreferredLanguage() : PreferredLanguage.PL,
|
||||
user.getNip(),
|
||||
user.getBirthDate(),
|
||||
user.isVerified(),
|
||||
user.isBlocked(),
|
||||
user.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package pl.polskalokalnie.config;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.ListingStatus;
|
||||
import pl.polskalokalnie.listing.OfferType;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.listing.PropertyType;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.AuthProvider;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@Component
|
||||
public class DataSeeder implements CommandLineRunner {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final ListingRepository listingRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final String adminEmail;
|
||||
private final String adminPassword;
|
||||
private final String adminName;
|
||||
|
||||
public DataSeeder(
|
||||
UserRepository userRepository,
|
||||
ListingRepository listingRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
@Value("${app.admin.email:admin@mieszko.pl}") String adminEmail,
|
||||
@Value("${app.admin.password:Admin123!}") String adminPassword,
|
||||
@Value("${app.admin.name:Administrator Mieszko}") String adminName
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.listingRepository = listingRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.adminEmail = adminEmail;
|
||||
this.adminPassword = adminPassword;
|
||||
this.adminName = adminName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
seedAdmin();
|
||||
seedDemoListings();
|
||||
}
|
||||
|
||||
private void seedAdmin() {
|
||||
if (userRepository.existsByEmailIgnoreCase(adminEmail)) {
|
||||
return;
|
||||
}
|
||||
AppUser admin = new AppUser();
|
||||
admin.setEmail(adminEmail.toLowerCase());
|
||||
admin.setFullName(adminName);
|
||||
admin.setPasswordHash(passwordEncoder.encode(adminPassword));
|
||||
admin.setRole(Role.ADMIN);
|
||||
admin.setProvider(AuthProvider.LOCAL);
|
||||
admin.setVerified(true);
|
||||
userRepository.save(admin);
|
||||
}
|
||||
|
||||
private void seedDemoListings() {
|
||||
if (listingRepository.count() > 0) {
|
||||
return;
|
||||
}
|
||||
listingRepository.save(demoListing(
|
||||
"Słoneczne 3 pokoje na Mokotowie", "Rozkładowe mieszkanie po remoncie, blisko metra.",
|
||||
OfferType.SALE, PropertyType.APARTMENT, "Warszawa", "ul. Puławska 120",
|
||||
new BigDecimal("749000"), 62.0, 3, ListingStatus.APPROVED));
|
||||
listingRepository.save(demoListing(
|
||||
"Dom z ogrodem pod Krakowem", "Wolnostojący dom, działka 600 m², cicha okolica.",
|
||||
OfferType.SALE, PropertyType.HOUSE, "Kraków", "ul. Podgórska 8",
|
||||
new BigDecimal("1290000"), 145.0, 5, ListingStatus.PENDING));
|
||||
listingRepository.save(demoListing(
|
||||
"Kawalerka do wynajęcia — Wrocław", "Umeblowana kawalerka w centrum, dostępna od zaraz.",
|
||||
OfferType.RENT, PropertyType.APARTMENT, "Wrocław", "ul. Krupnicza 3",
|
||||
new BigDecimal("2600"), 30.0, 1, ListingStatus.PENDING));
|
||||
}
|
||||
|
||||
private PropertyListing demoListing(
|
||||
String title, String description, OfferType offerType, PropertyType propertyType,
|
||||
String city, String address, BigDecimal price, Double area, Integer rooms, ListingStatus status
|
||||
) {
|
||||
PropertyListing listing = new PropertyListing();
|
||||
listing.setTitle(title);
|
||||
listing.setDescription(description);
|
||||
listing.setOfferType(offerType);
|
||||
listing.setPropertyType(propertyType);
|
||||
listing.setCity(city);
|
||||
listing.setAddress(address);
|
||||
listing.setPrice(price);
|
||||
listing.setArea(area);
|
||||
listing.setRooms(rooms);
|
||||
listing.setOwnerEmail("demo.user@mieszko.pl");
|
||||
listing.setStatus(status);
|
||||
return listing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package pl.polskalokalnie.config;
|
||||
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@Configuration
|
||||
public class SchemaFixer {
|
||||
|
||||
@Bean
|
||||
ApplicationRunner ensureViewsCountColumn(JdbcTemplate jdbcTemplate) {
|
||||
return args -> {
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE property_listings ADD COLUMN IF NOT EXISTS views_count BIGINT NOT NULL DEFAULT 0"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE app_users ADD COLUMN IF NOT EXISTS address VARCHAR(255)"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE app_users ADD COLUMN IF NOT EXISTS contact_preference VARCHAR(20)"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"UPDATE app_users SET contact_preference = 'EMAIL_AND_PHONE' WHERE contact_preference IS NULL"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE app_users ALTER COLUMN contact_preference SET DEFAULT 'EMAIL_AND_PHONE'"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE app_users ALTER COLUMN contact_preference SET NOT NULL"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE app_users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10)"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"UPDATE app_users SET preferred_language = 'PL' WHERE preferred_language IS NULL"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE app_users ALTER COLUMN preferred_language SET DEFAULT 'PL'"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE app_users ALTER COLUMN preferred_language SET NOT NULL"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS file_type VARCHAR(120)"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"ALTER TABLE IF EXISTS listing_report_attachments ADD COLUMN IF NOT EXISTS data_url TEXT"
|
||||
);
|
||||
|
||||
String dataUrlType = jdbcTemplate.query(
|
||||
"SELECT data_type FROM information_schema.columns WHERE table_name = 'listing_report_attachments' AND column_name = 'data_url'",
|
||||
rs -> rs.next() ? rs.getString(1) : null
|
||||
);
|
||||
|
||||
if ("oid".equalsIgnoreCase(dataUrlType)) {
|
||||
jdbcTemplate.execute("ALTER TABLE listing_report_attachments ADD COLUMN IF NOT EXISTS data_url_text TEXT");
|
||||
jdbcTemplate.execute(
|
||||
"UPDATE listing_report_attachments " +
|
||||
"SET data_url_text = CASE WHEN data_url IS NULL THEN NULL ELSE convert_from(lo_get(data_url), 'UTF8') END"
|
||||
);
|
||||
jdbcTemplate.execute(
|
||||
"DO $$ " +
|
||||
"BEGIN " +
|
||||
" IF EXISTS (SELECT 1 FROM listing_report_attachments WHERE data_url IS NOT NULL) THEN " +
|
||||
" PERFORM lo_unlink(data_url) FROM listing_report_attachments WHERE data_url IS NOT NULL; " +
|
||||
" END IF; " +
|
||||
"END $$"
|
||||
);
|
||||
jdbcTemplate.execute("ALTER TABLE listing_report_attachments DROP COLUMN data_url");
|
||||
jdbcTemplate.execute("ALTER TABLE listing_report_attachments RENAME COLUMN data_url_text TO data_url");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pl.polskalokalnie.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOriginPatterns(
|
||||
"http://localhost:[*]",
|
||||
"http://127.0.0.1:[*]",
|
||||
"http://192.168.*.*:[*]",
|
||||
"http://10.*.*.*:[*]",
|
||||
"http://172.16.*.*:[*]",
|
||||
"http://172.17.*.*:[*]",
|
||||
"http://172.18.*.*:[*]",
|
||||
"http://172.19.*.*:[*]",
|
||||
"http://172.20.*.*:[*]",
|
||||
"http://172.21.*.*:[*]",
|
||||
"http://172.22.*.*:[*]",
|
||||
"http://172.23.*.*:[*]",
|
||||
"http://172.24.*.*:[*]",
|
||||
"http://172.25.*.*:[*]",
|
||||
"http://172.26.*.*:[*]",
|
||||
"http://172.27.*.*:[*]",
|
||||
"http://172.28.*.*:[*]",
|
||||
"http://172.29.*.*:[*]",
|
||||
"http://172.30.*.*:[*]",
|
||||
"http://172.31.*.*:[*]",
|
||||
"http://*.local:[*]")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package pl.polskalokalnie.i18n;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
|
||||
@Service
|
||||
public class RuntimeTranslationService {
|
||||
|
||||
private static final int MAX_TEXTS_PER_REQUEST = 180;
|
||||
private static final int MAX_TEXT_LENGTH = 400;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(6))
|
||||
.build();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final Map<String, String> cache = new ConcurrentHashMap<>();
|
||||
|
||||
public Map<String, String> translateBatch(PreferredLanguage language, List<String> texts) {
|
||||
if (texts == null || texts.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Brak tekstów do tłumaczenia");
|
||||
}
|
||||
if (texts.size() > MAX_TEXTS_PER_REQUEST) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Zbyt wiele tekstów do tłumaczenia w jednym żądaniu");
|
||||
}
|
||||
|
||||
if (language == PreferredLanguage.PL) {
|
||||
Map<String, String> passthrough = new LinkedHashMap<>();
|
||||
texts.forEach((text) -> passthrough.put(text, text));
|
||||
return passthrough;
|
||||
}
|
||||
|
||||
String targetCode = toGoogleLang(language);
|
||||
Map<String, String> results = new LinkedHashMap<>();
|
||||
List<String> toTranslate = new ArrayList<>();
|
||||
|
||||
for (String text : texts) {
|
||||
String safeText = text != null ? text : "";
|
||||
if (safeText.length() > MAX_TEXT_LENGTH) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Jeden z tekstów jest zbyt długi do tłumaczenia");
|
||||
}
|
||||
|
||||
String cacheKey = targetCode + "|" + safeText;
|
||||
String cached = cache.get(cacheKey);
|
||||
if (cached != null) {
|
||||
results.put(safeText, cached);
|
||||
} else {
|
||||
toTranslate.add(safeText);
|
||||
}
|
||||
}
|
||||
|
||||
for (String text : toTranslate) {
|
||||
String translated = translateSingle(text, targetCode);
|
||||
cache.put(targetCode + "|" + text, translated);
|
||||
results.put(text, translated);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private String translateSingle(String source, String targetCode) {
|
||||
if (source.isBlank()) {
|
||||
return source;
|
||||
}
|
||||
|
||||
String encoded = URLEncoder.encode(source, StandardCharsets.UTF_8);
|
||||
String url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=pl&tl="
|
||||
+ targetCode + "&dt=t&q=" + encoded;
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.GET()
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Accept", "application/json")
|
||||
.header("User-Agent", "PolskaLokalnie/1.0")
|
||||
.build();
|
||||
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Usługa tłumaczeń jest chwilowo niedostępna");
|
||||
}
|
||||
return parseTranslatedText(response.body(), source);
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
|
||||
} catch (IOException ex) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "Nie udało się pobrać tłumaczenia", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private String parseTranslatedText(String payload, String fallback) throws IOException {
|
||||
JsonNode root = objectMapper.readTree(payload);
|
||||
JsonNode topSegments = root.path(0);
|
||||
if (!topSegments.isArray() || topSegments.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
StringBuilder translated = new StringBuilder();
|
||||
for (JsonNode segment : topSegments) {
|
||||
JsonNode part = segment.path(0);
|
||||
if (part.isTextual()) {
|
||||
translated.append(part.asText());
|
||||
}
|
||||
}
|
||||
String value = translated.toString();
|
||||
return value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
private String toGoogleLang(PreferredLanguage language) {
|
||||
return switch (language) {
|
||||
case EN -> "en";
|
||||
case UK -> "uk";
|
||||
case DE -> "de";
|
||||
case PL -> "pl";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package pl.polskalokalnie.i18n;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
|
||||
public record TranslateRequest(
|
||||
@NotNull PreferredLanguage language,
|
||||
@NotEmpty List<String> texts
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package pl.polskalokalnie.i18n;
|
||||
|
||||
import java.util.Map;
|
||||
import pl.polskalokalnie.user.PreferredLanguage;
|
||||
|
||||
public record TranslateResponse(
|
||||
PreferredLanguage language,
|
||||
Map<String, String> translations
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package pl.polskalokalnie.i18n;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/i18n")
|
||||
public class TranslationController {
|
||||
|
||||
private final RuntimeTranslationService runtimeTranslationService;
|
||||
|
||||
public TranslationController(RuntimeTranslationService runtimeTranslationService) {
|
||||
this.runtimeTranslationService = runtimeTranslationService;
|
||||
}
|
||||
|
||||
@PostMapping("/translate")
|
||||
public TranslateResponse translate(@Valid @RequestBody TranslateRequest request) {
|
||||
List<String> normalizedTexts = request.texts().stream()
|
||||
.map(text -> text == null ? "" : text)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
Map<String, String> translations = runtimeTranslationService.translateBatch(request.language(), normalizedTexts);
|
||||
return new TranslateResponse(request.language(), translations);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/listings")
|
||||
public class ListingController {
|
||||
|
||||
private final ListingService listingService;
|
||||
|
||||
public ListingController(ListingService listingService) {
|
||||
this.listingService = listingService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public List<ListingResponse> search(
|
||||
@RequestParam(required = false) String city,
|
||||
@RequestParam(required = false) OfferType offerType,
|
||||
@RequestParam(required = false) PropertyType propertyType
|
||||
) {
|
||||
return listingService.search(city, offerType, propertyType);
|
||||
}
|
||||
|
||||
@GetMapping("/mine")
|
||||
public List<ListingResponse> mine(Authentication authentication) {
|
||||
return listingService.findMine(authentication.getName());
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ListingDetailResponse getById(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(defaultValue = "false") boolean incrementView
|
||||
) {
|
||||
return listingService.getById(id, incrementView);
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<ListingDetailResponse> create(
|
||||
@Valid @RequestBody ListingCreateRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
ListingDetailResponse response = listingService.create(request, authentication.getName());
|
||||
return ResponseEntity
|
||||
.created(URI.create("/api/listings/" + response.id()))
|
||||
.body(response);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
public record ListingCreateRequest(
|
||||
@NotBlank String title,
|
||||
@NotBlank String description,
|
||||
@NotNull OfferType offerType,
|
||||
@NotNull PropertyType propertyType,
|
||||
@NotBlank String city,
|
||||
String address,
|
||||
@NotNull @Positive BigDecimal price,
|
||||
@NotNull @Positive Double area,
|
||||
@NotNull @Min(1) Integer rooms,
|
||||
// --- pola opcjonalne ---
|
||||
String market,
|
||||
String district,
|
||||
String street,
|
||||
String building,
|
||||
String floor,
|
||||
String buildingFloors,
|
||||
Integer yearBuilt,
|
||||
String condition,
|
||||
String ownership,
|
||||
String contactName,
|
||||
String contactPhone,
|
||||
String contactEmail,
|
||||
BigDecimal rentExtra,
|
||||
String availableFrom,
|
||||
String furnishing,
|
||||
String buildingType,
|
||||
String buildingMaterial,
|
||||
String windows,
|
||||
String exposure,
|
||||
String roomHeight,
|
||||
Double lat,
|
||||
Double lng,
|
||||
List<String> media,
|
||||
List<String> amenities,
|
||||
List<String> photos
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Pelna reprezentacja ogloszenia dla widoku szczegolow - zawiera wszystkie pola z formularza
|
||||
* dodawania oraz cala galerie zdjec (data URL).
|
||||
*/
|
||||
public record ListingDetailResponse(
|
||||
Long id,
|
||||
String title,
|
||||
String description,
|
||||
OfferType offerType,
|
||||
PropertyType propertyType,
|
||||
String city,
|
||||
String district,
|
||||
String street,
|
||||
String building,
|
||||
String address,
|
||||
BigDecimal price,
|
||||
Double area,
|
||||
Integer rooms,
|
||||
String floor,
|
||||
String buildingFloors,
|
||||
String market,
|
||||
Integer yearBuilt,
|
||||
String condition,
|
||||
String ownership,
|
||||
String contactName,
|
||||
String contactPhone,
|
||||
String contactEmail,
|
||||
BigDecimal rentExtra,
|
||||
String availableFrom,
|
||||
String furnishing,
|
||||
String buildingType,
|
||||
String buildingMaterial,
|
||||
String windows,
|
||||
String exposure,
|
||||
String roomHeight,
|
||||
Double lat,
|
||||
Double lng,
|
||||
List<String> media,
|
||||
List<String> amenities,
|
||||
List<String> photos,
|
||||
String ownerEmail,
|
||||
ListingStatus status,
|
||||
Instant createdAt,
|
||||
Long viewsCount
|
||||
) {
|
||||
public static ListingDetailResponse from(PropertyListing listing) {
|
||||
return new ListingDetailResponse(
|
||||
listing.getId(),
|
||||
listing.getTitle(),
|
||||
listing.getDescription(),
|
||||
listing.getOfferType(),
|
||||
listing.getPropertyType(),
|
||||
listing.getCity(),
|
||||
listing.getDistrict(),
|
||||
listing.getStreet(),
|
||||
listing.getBuilding(),
|
||||
listing.getAddress(),
|
||||
listing.getPrice(),
|
||||
listing.getArea(),
|
||||
listing.getRooms(),
|
||||
listing.getFloor(),
|
||||
listing.getBuildingFloors(),
|
||||
listing.getMarket(),
|
||||
listing.getYearBuilt(),
|
||||
listing.getCondition(),
|
||||
listing.getOwnership(),
|
||||
listing.getContactName(),
|
||||
listing.getContactPhone(),
|
||||
listing.getContactEmail(),
|
||||
listing.getRentExtra(),
|
||||
listing.getAvailableFrom(),
|
||||
listing.getFurnishing(),
|
||||
listing.getBuildingType(),
|
||||
listing.getBuildingMaterial(),
|
||||
listing.getWindows(),
|
||||
listing.getExposure(),
|
||||
listing.getRoomHeight(),
|
||||
listing.getLat(),
|
||||
listing.getLng(),
|
||||
List.copyOf(listing.getMedia()),
|
||||
List.copyOf(listing.getAmenities()),
|
||||
List.copyOf(listing.getPhotos()),
|
||||
listing.getOwnerEmail(),
|
||||
listing.getStatus(),
|
||||
listing.getCreatedAt(),
|
||||
listing.getViewsCount()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ListingRepository extends JpaRepository<PropertyListing, Long> {
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Lekka reprezentacja ogloszenia do list (Kupuje/Wynajmuje, moderacja, moje ogloszenia).
|
||||
* Nie zawiera pelnej galerii ani pol szczegolowych - od tego jest {@link ListingDetailResponse}.
|
||||
*/
|
||||
public record ListingResponse(
|
||||
Long id,
|
||||
String title,
|
||||
String description,
|
||||
OfferType offerType,
|
||||
PropertyType propertyType,
|
||||
String city,
|
||||
String district,
|
||||
String address,
|
||||
BigDecimal price,
|
||||
Double area,
|
||||
Integer rooms,
|
||||
String floor,
|
||||
String buildingFloors,
|
||||
String market,
|
||||
Integer yearBuilt,
|
||||
String coverPhoto,
|
||||
String ownerEmail,
|
||||
ListingStatus status,
|
||||
Instant createdAt
|
||||
) {
|
||||
public static ListingResponse from(PropertyListing listing) {
|
||||
return new ListingResponse(
|
||||
listing.getId(),
|
||||
listing.getTitle(),
|
||||
listing.getDescription(),
|
||||
listing.getOfferType(),
|
||||
listing.getPropertyType(),
|
||||
listing.getCity(),
|
||||
listing.getDistrict(),
|
||||
listing.getAddress(),
|
||||
listing.getPrice(),
|
||||
listing.getArea(),
|
||||
listing.getRooms(),
|
||||
listing.getFloor(),
|
||||
listing.getBuildingFloors(),
|
||||
listing.getMarket(),
|
||||
listing.getYearBuilt(),
|
||||
listing.getCoverPhoto(),
|
||||
listing.getOwnerEmail(),
|
||||
listing.getStatus(),
|
||||
listing.getCreatedAt()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
|
||||
@Service
|
||||
public class ListingService {
|
||||
|
||||
private static final int MAX_PHOTOS = 8;
|
||||
|
||||
private final ListingRepository listingRepository;
|
||||
private final TextModerationService textModerationService;
|
||||
|
||||
public ListingService(ListingRepository listingRepository, TextModerationService textModerationService) {
|
||||
this.listingRepository = listingRepository;
|
||||
this.textModerationService = textModerationService;
|
||||
}
|
||||
|
||||
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
|
||||
return listingRepository.findAll().stream()
|
||||
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
|
||||
.filter(listing -> city == null || listing.getCity().equalsIgnoreCase(city.trim()))
|
||||
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
|
||||
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
|
||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
||||
.map(ListingResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ListingDetailResponse getById(Long id, boolean incrementView) {
|
||||
PropertyListing listing = listingRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
||||
|
||||
if (incrementView) {
|
||||
listing.setViewsCount((listing.getViewsCount() == null ? 0L : listing.getViewsCount()) + 1L);
|
||||
listing = listingRepository.save(listing);
|
||||
}
|
||||
|
||||
return ListingDetailResponse.from(listing);
|
||||
}
|
||||
|
||||
public List<ListingResponse> findMine(String ownerEmail) {
|
||||
return listingRepository.findAll().stream()
|
||||
.filter(listing -> ownerEmail != null && ownerEmail.equalsIgnoreCase(listing.getOwnerEmail()))
|
||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
||||
.map(ListingResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ListingDetailResponse create(ListingCreateRequest request, String ownerEmail) {
|
||||
textModerationService.validateOrThrow(
|
||||
request.title(), request.description(), request.city(), request.address(),
|
||||
request.district(), request.street(), request.contactName());
|
||||
|
||||
PropertyListing listing = new PropertyListing();
|
||||
listing.setTitle(request.title().trim());
|
||||
listing.setDescription(request.description().trim());
|
||||
listing.setOfferType(request.offerType());
|
||||
listing.setPropertyType(request.propertyType());
|
||||
listing.setCity(request.city().trim());
|
||||
listing.setAddress(trimToNull(request.address()));
|
||||
listing.setPrice(request.price());
|
||||
listing.setArea(request.area());
|
||||
listing.setRooms(request.rooms());
|
||||
|
||||
listing.setMarket(trimToNull(request.market()));
|
||||
listing.setDistrict(trimToNull(request.district()));
|
||||
listing.setStreet(trimToNull(request.street()));
|
||||
listing.setBuilding(trimToNull(request.building()));
|
||||
listing.setFloor(trimToNull(request.floor()));
|
||||
listing.setBuildingFloors(trimToNull(request.buildingFloors()));
|
||||
listing.setYearBuilt(request.yearBuilt());
|
||||
listing.setCondition(trimToNull(request.condition()));
|
||||
listing.setOwnership(trimToNull(request.ownership()));
|
||||
listing.setContactName(trimToNull(request.contactName()));
|
||||
listing.setContactPhone(trimToNull(request.contactPhone()));
|
||||
listing.setContactEmail(trimToNull(request.contactEmail()));
|
||||
listing.setRentExtra(request.rentExtra());
|
||||
listing.setAvailableFrom(trimToNull(request.availableFrom()));
|
||||
listing.setFurnishing(trimToNull(request.furnishing()));
|
||||
listing.setBuildingType(trimToNull(request.buildingType()));
|
||||
listing.setBuildingMaterial(trimToNull(request.buildingMaterial()));
|
||||
listing.setWindows(trimToNull(request.windows()));
|
||||
listing.setExposure(trimToNull(request.exposure()));
|
||||
listing.setRoomHeight(trimToNull(request.roomHeight()));
|
||||
listing.setLat(request.lat());
|
||||
listing.setLng(request.lng());
|
||||
|
||||
listing.setMedia(cleanList(request.media()));
|
||||
listing.setAmenities(cleanList(request.amenities()));
|
||||
|
||||
List<String> photos = limitPhotos(request.photos());
|
||||
listing.setPhotos(photos);
|
||||
listing.setCoverPhoto(photos.isEmpty() ? null : photos.get(0));
|
||||
|
||||
listing.setOwnerEmail(ownerEmail);
|
||||
listing.setStatus(ListingStatus.PENDING);
|
||||
|
||||
return ListingDetailResponse.from(listingRepository.save(listing));
|
||||
}
|
||||
|
||||
// --- Moderacja (admin) ---
|
||||
|
||||
public List<ListingResponse> findAllForModeration() {
|
||||
return listingRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(PropertyListing::getCreatedAt).reversed())
|
||||
.map(ListingResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public ListingResponse changeStatus(Long id, ListingStatus status) {
|
||||
PropertyListing listing = listingRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
||||
listing.setStatus(status);
|
||||
return ListingResponse.from(listingRepository.save(listing));
|
||||
}
|
||||
|
||||
public void delete(Long id) {
|
||||
if (!listingRepository.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found");
|
||||
}
|
||||
listingRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private static List<String> cleanList(List<String> values) {
|
||||
List<String> cleaned = new ArrayList<>();
|
||||
if (values != null) {
|
||||
for (String value : values) {
|
||||
String trimmed = trimToNull(value);
|
||||
if (trimmed != null && !cleaned.contains(trimmed)) {
|
||||
cleaned.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static List<String> limitPhotos(List<String> photos) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (photos != null) {
|
||||
for (String photo : photos) {
|
||||
if (photo != null && !photo.isBlank()) {
|
||||
result.add(photo);
|
||||
if (result.size() >= MAX_PHOTOS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
public enum ListingStatus {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
public enum OfferType {
|
||||
SALE,
|
||||
RENT
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.OrderColumn;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "property_listings")
|
||||
public class PropertyListing {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, length = 160)
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false, length = 3000)
|
||||
private String description;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private OfferType offerType;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 30)
|
||||
private PropertyType propertyType;
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
private String city;
|
||||
|
||||
@Column(length = 220)
|
||||
private String address;
|
||||
|
||||
@Column(nullable = false, precision = 14, scale = 2)
|
||||
private BigDecimal price;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Double area;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Integer rooms;
|
||||
|
||||
// --- Dodatkowe pola z formularza dodawania ogloszenia (wszystkie opcjonalne) ---
|
||||
|
||||
@Column(length = 30)
|
||||
private String market;
|
||||
|
||||
@Column(length = 120)
|
||||
private String district;
|
||||
|
||||
@Column(length = 160)
|
||||
private String street;
|
||||
|
||||
@Column(length = 30)
|
||||
private String building;
|
||||
|
||||
@Column(length = 20)
|
||||
private String floor;
|
||||
|
||||
@Column(length = 20)
|
||||
private String buildingFloors;
|
||||
|
||||
private Integer yearBuilt;
|
||||
|
||||
@Column(length = 40)
|
||||
private String condition;
|
||||
|
||||
@Column(length = 40)
|
||||
private String ownership;
|
||||
|
||||
@Column(length = 120)
|
||||
private String contactName;
|
||||
|
||||
@Column(length = 40)
|
||||
private String contactPhone;
|
||||
|
||||
@Column(length = 180)
|
||||
private String contactEmail;
|
||||
|
||||
// Dodatkowy czynsz (dla wynajmu)
|
||||
@Column(precision = 14, scale = 2)
|
||||
private BigDecimal rentExtra;
|
||||
|
||||
@Column(length = 40)
|
||||
private String availableFrom;
|
||||
|
||||
@Column(length = 40)
|
||||
private String furnishing;
|
||||
|
||||
@Column(length = 40)
|
||||
private String buildingType;
|
||||
|
||||
@Column(length = 40)
|
||||
private String buildingMaterial;
|
||||
|
||||
@Column(length = 40)
|
||||
private String windows;
|
||||
|
||||
@Column(length = 40)
|
||||
private String exposure;
|
||||
|
||||
@Column(length = 20)
|
||||
private String roomHeight;
|
||||
|
||||
private Double lat;
|
||||
|
||||
private Double lng;
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "listing_media", joinColumns = @JoinColumn(name = "listing_id"))
|
||||
@Column(name = "value", length = 60)
|
||||
private List<String> media = new ArrayList<>();
|
||||
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "listing_amenities", joinColumns = @JoinColumn(name = "listing_id"))
|
||||
@Column(name = "value", length = 60)
|
||||
private List<String> amenities = new ArrayList<>();
|
||||
|
||||
// Zdjecia trzymane jako data URL (base64). Osobna tabela z kolumna TEXT.
|
||||
@ElementCollection
|
||||
@CollectionTable(name = "listing_photos", joinColumns = @JoinColumn(name = "listing_id"))
|
||||
@OrderColumn(name = "position")
|
||||
@Column(name = "photo", columnDefinition = "text")
|
||||
private List<String> photos = new ArrayList<>();
|
||||
|
||||
// Pierwsze zdjecie skopiowane jako okladka, zeby listy nie musialy ladowac calej galerii.
|
||||
@Column(columnDefinition = "text")
|
||||
private String coverPhoto;
|
||||
|
||||
@Column(length = 180)
|
||||
private String ownerEmail;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private ListingStatus status = ListingStatus.PENDING;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long viewsCount = 0L;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void setCreatedAtOnInsert() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getTitle() {
|
||||
return title;
|
||||
}
|
||||
|
||||
public void setTitle(String title) {
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
public void setDescription(String description) {
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
public OfferType getOfferType() {
|
||||
return offerType;
|
||||
}
|
||||
|
||||
public void setOfferType(OfferType offerType) {
|
||||
this.offerType = offerType;
|
||||
}
|
||||
|
||||
public PropertyType getPropertyType() {
|
||||
return propertyType;
|
||||
}
|
||||
|
||||
public void setPropertyType(PropertyType propertyType) {
|
||||
this.propertyType = propertyType;
|
||||
}
|
||||
|
||||
public String getCity() {
|
||||
return city;
|
||||
}
|
||||
|
||||
public void setCity(String city) {
|
||||
this.city = city;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public void setAddress(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public BigDecimal getPrice() {
|
||||
return price;
|
||||
}
|
||||
|
||||
public void setPrice(BigDecimal price) {
|
||||
this.price = price;
|
||||
}
|
||||
|
||||
public Double getArea() {
|
||||
return area;
|
||||
}
|
||||
|
||||
public void setArea(Double area) {
|
||||
this.area = area;
|
||||
}
|
||||
|
||||
public Integer getRooms() {
|
||||
return rooms;
|
||||
}
|
||||
|
||||
public void setRooms(Integer rooms) {
|
||||
this.rooms = rooms;
|
||||
}
|
||||
|
||||
public String getMarket() {
|
||||
return market;
|
||||
}
|
||||
|
||||
public void setMarket(String market) {
|
||||
this.market = market;
|
||||
}
|
||||
|
||||
public String getDistrict() {
|
||||
return district;
|
||||
}
|
||||
|
||||
public void setDistrict(String district) {
|
||||
this.district = district;
|
||||
}
|
||||
|
||||
public String getStreet() {
|
||||
return street;
|
||||
}
|
||||
|
||||
public void setStreet(String street) {
|
||||
this.street = street;
|
||||
}
|
||||
|
||||
public String getBuilding() {
|
||||
return building;
|
||||
}
|
||||
|
||||
public void setBuilding(String building) {
|
||||
this.building = building;
|
||||
}
|
||||
|
||||
public String getFloor() {
|
||||
return floor;
|
||||
}
|
||||
|
||||
public void setFloor(String floor) {
|
||||
this.floor = floor;
|
||||
}
|
||||
|
||||
public String getBuildingFloors() {
|
||||
return buildingFloors;
|
||||
}
|
||||
|
||||
public void setBuildingFloors(String buildingFloors) {
|
||||
this.buildingFloors = buildingFloors;
|
||||
}
|
||||
|
||||
public Integer getYearBuilt() {
|
||||
return yearBuilt;
|
||||
}
|
||||
|
||||
public void setYearBuilt(Integer yearBuilt) {
|
||||
this.yearBuilt = yearBuilt;
|
||||
}
|
||||
|
||||
public String getCondition() {
|
||||
return condition;
|
||||
}
|
||||
|
||||
public void setCondition(String condition) {
|
||||
this.condition = condition;
|
||||
}
|
||||
|
||||
public String getOwnership() {
|
||||
return ownership;
|
||||
}
|
||||
|
||||
public void setOwnership(String ownership) {
|
||||
this.ownership = ownership;
|
||||
}
|
||||
|
||||
public String getContactName() {
|
||||
return contactName;
|
||||
}
|
||||
|
||||
public void setContactName(String contactName) {
|
||||
this.contactName = contactName;
|
||||
}
|
||||
|
||||
public String getContactPhone() {
|
||||
return contactPhone;
|
||||
}
|
||||
|
||||
public void setContactPhone(String contactPhone) {
|
||||
this.contactPhone = contactPhone;
|
||||
}
|
||||
|
||||
public String getContactEmail() {
|
||||
return contactEmail;
|
||||
}
|
||||
|
||||
public void setContactEmail(String contactEmail) {
|
||||
this.contactEmail = contactEmail;
|
||||
}
|
||||
|
||||
public BigDecimal getRentExtra() {
|
||||
return rentExtra;
|
||||
}
|
||||
|
||||
public void setRentExtra(BigDecimal rentExtra) {
|
||||
this.rentExtra = rentExtra;
|
||||
}
|
||||
|
||||
public String getAvailableFrom() {
|
||||
return availableFrom;
|
||||
}
|
||||
|
||||
public void setAvailableFrom(String availableFrom) {
|
||||
this.availableFrom = availableFrom;
|
||||
}
|
||||
|
||||
public String getFurnishing() {
|
||||
return furnishing;
|
||||
}
|
||||
|
||||
public void setFurnishing(String furnishing) {
|
||||
this.furnishing = furnishing;
|
||||
}
|
||||
|
||||
public String getBuildingType() {
|
||||
return buildingType;
|
||||
}
|
||||
|
||||
public void setBuildingType(String buildingType) {
|
||||
this.buildingType = buildingType;
|
||||
}
|
||||
|
||||
public String getBuildingMaterial() {
|
||||
return buildingMaterial;
|
||||
}
|
||||
|
||||
public void setBuildingMaterial(String buildingMaterial) {
|
||||
this.buildingMaterial = buildingMaterial;
|
||||
}
|
||||
|
||||
public String getWindows() {
|
||||
return windows;
|
||||
}
|
||||
|
||||
public void setWindows(String windows) {
|
||||
this.windows = windows;
|
||||
}
|
||||
|
||||
public String getExposure() {
|
||||
return exposure;
|
||||
}
|
||||
|
||||
public void setExposure(String exposure) {
|
||||
this.exposure = exposure;
|
||||
}
|
||||
|
||||
public String getRoomHeight() {
|
||||
return roomHeight;
|
||||
}
|
||||
|
||||
public void setRoomHeight(String roomHeight) {
|
||||
this.roomHeight = roomHeight;
|
||||
}
|
||||
|
||||
public Double getLat() {
|
||||
return lat;
|
||||
}
|
||||
|
||||
public void setLat(Double lat) {
|
||||
this.lat = lat;
|
||||
}
|
||||
|
||||
public Double getLng() {
|
||||
return lng;
|
||||
}
|
||||
|
||||
public void setLng(Double lng) {
|
||||
this.lng = lng;
|
||||
}
|
||||
|
||||
public List<String> getMedia() {
|
||||
return media;
|
||||
}
|
||||
|
||||
public void setMedia(List<String> media) {
|
||||
this.media = media;
|
||||
}
|
||||
|
||||
public List<String> getAmenities() {
|
||||
return amenities;
|
||||
}
|
||||
|
||||
public void setAmenities(List<String> amenities) {
|
||||
this.amenities = amenities;
|
||||
}
|
||||
|
||||
public List<String> getPhotos() {
|
||||
return photos;
|
||||
}
|
||||
|
||||
public void setPhotos(List<String> photos) {
|
||||
this.photos = photos;
|
||||
}
|
||||
|
||||
public String getCoverPhoto() {
|
||||
return coverPhoto;
|
||||
}
|
||||
|
||||
public void setCoverPhoto(String coverPhoto) {
|
||||
this.coverPhoto = coverPhoto;
|
||||
}
|
||||
|
||||
public String getOwnerEmail() {
|
||||
return ownerEmail;
|
||||
}
|
||||
|
||||
public void setOwnerEmail(String ownerEmail) {
|
||||
this.ownerEmail = ownerEmail;
|
||||
}
|
||||
|
||||
public ListingStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(ListingStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public Long getViewsCount() {
|
||||
return viewsCount;
|
||||
}
|
||||
|
||||
public void setViewsCount(Long viewsCount) {
|
||||
this.viewsCount = viewsCount == null ? 0L : Math.max(0L, viewsCount);
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.listing;
|
||||
|
||||
public enum PropertyType {
|
||||
APARTMENT,
|
||||
HOUSE
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package pl.polskalokalnie.message;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "messages")
|
||||
public class Message {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long senderId;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long recipientId;
|
||||
|
||||
@Column(nullable = false, length = 2000)
|
||||
private String content;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean read = false;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getSenderId() {
|
||||
return senderId;
|
||||
}
|
||||
|
||||
public void setSenderId(Long senderId) {
|
||||
this.senderId = senderId;
|
||||
}
|
||||
|
||||
public Long getRecipientId() {
|
||||
return recipientId;
|
||||
}
|
||||
|
||||
public void setRecipientId(Long recipientId) {
|
||||
this.recipientId = recipientId;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
return content;
|
||||
}
|
||||
|
||||
public void setContent(String content) {
|
||||
this.content = content;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public boolean isRead() {
|
||||
return read;
|
||||
}
|
||||
|
||||
public void setRead(boolean read) {
|
||||
this.read = read;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pl.polskalokalnie.message;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/messages")
|
||||
public class MessageController {
|
||||
|
||||
private final MessageService messageService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public MessageController(MessageService messageService, UserRepository userRepository) {
|
||||
this.messageService = messageService;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@GetMapping("/admin")
|
||||
public List<MessageResponse> conversationWithAdmin(Authentication authentication) {
|
||||
AppUser me = currentUser(authentication);
|
||||
AppUser admin = messageService.resolveAdmin();
|
||||
return messageService.getConversation(me.getId(), admin.getId());
|
||||
}
|
||||
|
||||
@PostMapping("/admin")
|
||||
public MessageResponse sendToAdmin(Authentication authentication, @Valid @RequestBody SendMessageRequest request) {
|
||||
AppUser me = currentUser(authentication);
|
||||
AppUser admin = messageService.resolveAdmin();
|
||||
return messageService.send(me.getId(), admin.getId(), request.content());
|
||||
}
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
public UnreadCountResponse unreadCount(Authentication authentication) {
|
||||
AppUser me = currentUser(authentication);
|
||||
AppUser admin = messageService.resolveAdmin();
|
||||
return new UnreadCountResponse(messageService.countUnreadFrom(me.getId(), admin.getId()));
|
||||
}
|
||||
|
||||
private AppUser currentUser(Authentication authentication) {
|
||||
return userRepository.findByEmailIgnoreCase(authentication.getName())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package pl.polskalokalnie.message;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
public interface MessageRepository extends JpaRepository<Message, Long> {
|
||||
|
||||
@Query("select m from Message m where (m.senderId = :first and m.recipientId = :second) "
|
||||
+ "or (m.senderId = :second and m.recipientId = :first) order by m.createdAt asc")
|
||||
List<Message> findConversation(@Param("first") Long first, @Param("second") Long second);
|
||||
|
||||
long countByRecipientIdAndSenderIdAndReadFalse(Long recipientId, Long senderId);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("update Message m set m.read = true where m.recipientId = :recipientId and m.senderId = :senderId and m.read = false")
|
||||
void markConversationRead(@Param("recipientId") Long recipientId, @Param("senderId") Long senderId);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package pl.polskalokalnie.message;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record MessageResponse(
|
||||
Long id,
|
||||
Long senderId,
|
||||
Long recipientId,
|
||||
String content,
|
||||
Instant createdAt,
|
||||
boolean mine
|
||||
) {
|
||||
public static MessageResponse from(Message message, Long currentUserId) {
|
||||
return new MessageResponse(
|
||||
message.getId(),
|
||||
message.getSenderId(),
|
||||
message.getRecipientId(),
|
||||
message.getContent(),
|
||||
message.getCreatedAt(),
|
||||
message.getSenderId().equals(currentUserId)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package pl.polskalokalnie.message;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.moderation.TextModerationService;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.Role;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@Service
|
||||
public class MessageService {
|
||||
|
||||
private final MessageRepository messageRepository;
|
||||
private final UserRepository userRepository;
|
||||
private final TextModerationService textModerationService;
|
||||
|
||||
public MessageService(
|
||||
MessageRepository messageRepository,
|
||||
UserRepository userRepository,
|
||||
TextModerationService textModerationService
|
||||
) {
|
||||
this.messageRepository = messageRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.textModerationService = textModerationService;
|
||||
}
|
||||
|
||||
public AppUser resolveAdmin() {
|
||||
return userRepository.findFirstByRole(Role.ADMIN)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Brak konta administratora"));
|
||||
}
|
||||
|
||||
public List<MessageResponse> getConversation(Long currentUserId, Long otherUserId) {
|
||||
messageRepository.markConversationRead(currentUserId, otherUserId);
|
||||
return messageRepository.findConversation(currentUserId, otherUserId).stream()
|
||||
.map(message -> MessageResponse.from(message, currentUserId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
public MessageResponse send(Long senderId, Long recipientId, String content) {
|
||||
textModerationService.validateOrThrow(content);
|
||||
|
||||
Message message = new Message();
|
||||
message.setSenderId(senderId);
|
||||
message.setRecipientId(recipientId);
|
||||
message.setContent(content.trim());
|
||||
Message saved = messageRepository.save(message);
|
||||
return MessageResponse.from(saved, senderId);
|
||||
}
|
||||
|
||||
public long countUnreadFrom(Long recipientId, Long senderId) {
|
||||
return messageRepository.countByRecipientIdAndSenderIdAndReadFalse(recipientId, senderId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package pl.polskalokalnie.message;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public record SendMessageRequest(
|
||||
@NotBlank(message = "Wiadomość nie może być pusta")
|
||||
@Size(max = 2000, message = "Wiadomość jest zbyt długa")
|
||||
String content
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package pl.polskalokalnie.message;
|
||||
|
||||
public record UnreadCountResponse(long count) {
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package pl.polskalokalnie.moderation;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "forbidden_words")
|
||||
public class ForbiddenWord {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 120)
|
||||
private String word;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getWord() {
|
||||
return word;
|
||||
}
|
||||
|
||||
public void setWord(String word) {
|
||||
this.word = word;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.moderation;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ForbiddenWordRepository extends JpaRepository<ForbiddenWord, Long> {
|
||||
|
||||
boolean existsByWordIgnoreCase(String word);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.moderation;
|
||||
|
||||
public record ForbiddenWordResponse(Long id, String word) {
|
||||
|
||||
public static ForbiddenWordResponse from(ForbiddenWord forbiddenWord) {
|
||||
return new ForbiddenWordResponse(forbiddenWord.getId(), forbiddenWord.getWord());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package pl.polskalokalnie.moderation;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import pl.polskalokalnie.settings.AppSetting;
|
||||
import pl.polskalokalnie.settings.AppSettingRepository;
|
||||
|
||||
/**
|
||||
* Wsiewa domyslny slownik wulgaryzmow/wyzwisk do tabeli forbidden_words dokladnie raz w zyciu bazy.
|
||||
* Dzieki temu baza jest jedynym zrodlem prawdy dla filtra tresci: brakujace slowa domyslne sa
|
||||
* dodawane niezaleznie od tego, ile slow admin juz mial, a pozniejsze usuniecia sa TRWALE
|
||||
* (po jednorazowym seedzie flaga jest ustawiona i slownik nie odradza sie przy restarcie).
|
||||
*/
|
||||
@Component
|
||||
@Order(0)
|
||||
public class ForbiddenWordSeeder implements CommandLineRunner {
|
||||
|
||||
private static final String SEED_FLAG_KEY = "forbidden_words_seeded";
|
||||
|
||||
private static final List<String> DEFAULT_WORDS = List.of(
|
||||
"kurwa", "kurwy", "kurwie", "kurwo", "kurwą", "kurwami", "kurwić", "kurwic", "kurwica",
|
||||
"kurwisko", "kurwiarz", "skurwysyn", "skurwysyna", "skurwysynu", "skurwysyny", "skurwiel",
|
||||
"skurwiele", "wkurwia", "wkurwiać", "wkurwiony", "pierdol", "pierdolić", "pierdolic",
|
||||
"pierdolę", "pierdole", "pierdolisz", "pierdolą", "pierdolony", "pierdolona", "pierdolone",
|
||||
"popierdolony", "rozpierdol", "rozpierdolić", "rozpierdalać", "wpierdol", "wpierdolić",
|
||||
"wpierdalać", "spierdalaj", "spierdalać", "spierdolić", "wypierdalaj", "wypierdalać",
|
||||
"wypierdolić", "zapierdalać", "zapierdala", "zapierdol", "odpierdolić", "przypierdalać",
|
||||
"chuj", "chuja", "chujowi", "chujem", "chuje", "chujek", "chujnia", "chujowy", "chujowa",
|
||||
"chujowe", "chujowo", "huj", "hujek", "hujnia", "hujowy", "pizda", "pizdy", "pizdzie",
|
||||
"pizdą", "pizdo", "pizduś", "pizdowaty", "pizdnąć", "cipa", "cipka", "cipki", "cipę",
|
||||
"cipie", "cipą", "jebać", "jebac", "jebie", "jebię", "jebiesz", "jebią", "jebany",
|
||||
"jebana", "jebane", "jebani", "jebnięty", "jebnąć", "jebnij", "dojebać", "odjebać",
|
||||
"przejebać", "wyjebać", "wyjebany", "wyjebane", "zajebać", "zajebisty", "zajebista",
|
||||
"zajebiste", "zajebiście", "najebany", "pojeb", "pojebany", "pojebana", "pojebane", "fiut",
|
||||
"fiuta", "fiuty", "kutas", "kutasa", "kutasy", "kutafon", "pała", "pały", "pałę",
|
||||
"szmata", "szmato", "szmaty", "dziwka", "dziwki", "dziwek", "suka", "suki", "suko",
|
||||
"sukinsyn", "gówno", "gowno", "gówniany", "gowniany", "gówniarz", "gowniarz", "gówniak",
|
||||
"gnojek", "gnoj", "debil", "debilu", "debile", "idiota", "idioci", "idiotka", "kretyn",
|
||||
"kretyni", "imbecyl", "imbecyle", "przygłup", "przyglup", "głupek", "glupek", "frajer",
|
||||
"frajerze", "lamus", "palant", "pajac", "baran", "osioł", "idiotyczny", "ścierwo", "scierwo"
|
||||
);
|
||||
|
||||
private final ForbiddenWordRepository forbiddenWordRepository;
|
||||
private final AppSettingRepository appSettingRepository;
|
||||
|
||||
public ForbiddenWordSeeder(
|
||||
ForbiddenWordRepository forbiddenWordRepository,
|
||||
AppSettingRepository appSettingRepository
|
||||
) {
|
||||
this.forbiddenWordRepository = forbiddenWordRepository;
|
||||
this.appSettingRepository = appSettingRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(String... args) {
|
||||
if (appSettingRepository.existsById(SEED_FLAG_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> existing = forbiddenWordRepository.findAll().stream()
|
||||
.map(word -> word.getWord().toLowerCase(Locale.ROOT))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
DEFAULT_WORDS.stream()
|
||||
.distinct()
|
||||
.filter(word -> !existing.contains(word.toLowerCase(Locale.ROOT)))
|
||||
.forEach(word -> {
|
||||
ForbiddenWord forbiddenWord = new ForbiddenWord();
|
||||
forbiddenWord.setWord(word);
|
||||
forbiddenWordRepository.save(forbiddenWord);
|
||||
});
|
||||
|
||||
appSettingRepository.save(new AppSetting(SEED_FLAG_KEY, "true"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package pl.polskalokalnie.moderation;
|
||||
|
||||
public record ModerationCheckRequest(String text) {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package pl.polskalokalnie.moderation;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Lekki endpoint dla frontendu: pozwala sprawdzic dowolne pole tekstowe przez ten sam globalny
|
||||
* filtr tresci co reszta backendu (np. czaty renderowane po stronie klienta). Zwraca 204, gdy
|
||||
* tekst jest czysty, albo 400 z komunikatem, gdy zawiera niedozwolone slownictwo.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/moderation")
|
||||
public class ModerationController {
|
||||
|
||||
private final TextModerationService textModerationService;
|
||||
|
||||
public ModerationController(TextModerationService textModerationService) {
|
||||
this.textModerationService = textModerationService;
|
||||
}
|
||||
|
||||
@PostMapping("/check")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
public void check(@RequestBody ModerationCheckRequest request) {
|
||||
textModerationService.validateOrThrow(request.text());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package pl.polskalokalnie.moderation;
|
||||
|
||||
import java.text.Normalizer;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Globalny filtr tresci. Jedynym zrodlem slow zakazanych jest tabela forbidden_words
|
||||
* (domyslny slownik wsiewa {@link ForbiddenWordSeeder}). Kazde pole tekstowe przechodzace
|
||||
* przez {@link #validateOrThrow} jest normalizowane tak, by wykryc celowe modyfikacje:
|
||||
* rozna wielkosc liter, polskie znaki, spacje/kropki/myslniki/podkreslenia, zamiana liter na
|
||||
* cyfry (leet) oraz wielokrotne powtarzanie liter (np. "kuuurwa", "k.u_r-w4").
|
||||
*/
|
||||
@Service
|
||||
public class TextModerationService {
|
||||
|
||||
private static final Locale POLISH_LOCALE = Locale.forLanguageTag("pl-PL");
|
||||
|
||||
private static final String BLOCK_MESSAGE =
|
||||
"Twoja wiadomość zawiera niedozwolone słownictwo. Usuń je i spróbuj ponownie.";
|
||||
|
||||
private final ForbiddenWordRepository forbiddenWordRepository;
|
||||
|
||||
public TextModerationService(ForbiddenWordRepository forbiddenWordRepository) {
|
||||
this.forbiddenWordRepository = forbiddenWordRepository;
|
||||
}
|
||||
|
||||
public void validateOrThrow(String... values) {
|
||||
if (values == null) {
|
||||
return;
|
||||
}
|
||||
for (String value : values) {
|
||||
if (containsProhibitedContent(value)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, BLOCK_MESSAGE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean containsProhibitedContent(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String normalized = normalize(value);
|
||||
if (normalized.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
for (String banned : allNormalizedWords()) {
|
||||
if (normalized.contains(banned)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public List<ForbiddenWordResponse> listForbiddenWords() {
|
||||
return forbiddenWordRepository.findAll().stream()
|
||||
.sorted(Comparator.comparing(ForbiddenWord::getWord, String.CASE_INSENSITIVE_ORDER))
|
||||
.map(ForbiddenWordResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<ForbiddenWordResponse> addForbiddenWord(String rawWord) {
|
||||
String cleaned = rawWord == null ? "" : rawWord.trim().toLowerCase(POLISH_LOCALE);
|
||||
if (cleaned.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Słowo nie może być puste");
|
||||
}
|
||||
|
||||
String normalized = normalize(cleaned);
|
||||
if (normalized.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieprawidłowe słowo");
|
||||
}
|
||||
|
||||
if (forbiddenWordRepository.existsByWordIgnoreCase(cleaned) || allNormalizedWords().contains(normalized)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "To słowo jest już na liście zakazanych");
|
||||
}
|
||||
|
||||
ForbiddenWord forbiddenWord = new ForbiddenWord();
|
||||
forbiddenWord.setWord(cleaned);
|
||||
forbiddenWordRepository.save(forbiddenWord);
|
||||
return listForbiddenWords();
|
||||
}
|
||||
|
||||
public void removeForbiddenWord(Long id) {
|
||||
if (!forbiddenWordRepository.existsById(id)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Słowo nie istnieje na liście");
|
||||
}
|
||||
forbiddenWordRepository.deleteById(id);
|
||||
}
|
||||
|
||||
private Set<String> allNormalizedWords() {
|
||||
Set<String> all = new LinkedHashSet<>();
|
||||
forbiddenWordRepository.findAll().stream()
|
||||
.map(ForbiddenWord::getWord)
|
||||
.map(TextModerationService::normalize)
|
||||
.filter(word -> !word.isBlank())
|
||||
.forEach(all::add);
|
||||
return all;
|
||||
}
|
||||
|
||||
private static String normalize(String value) {
|
||||
String lower = value.toLowerCase(Locale.ROOT);
|
||||
String deaccented = Normalizer.normalize(lower, Normalizer.Form.NFD)
|
||||
.replaceAll("\\p{M}+", "");
|
||||
|
||||
StringBuilder lettersOnly = new StringBuilder(deaccented.length());
|
||||
for (int i = 0; i < deaccented.length(); i++) {
|
||||
char c = mapLeet(deaccented.charAt(i));
|
||||
if (Character.isLetter(c)) {
|
||||
lettersOnly.append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return collapseRepeatingLetters(lettersOnly.toString());
|
||||
}
|
||||
|
||||
private static char mapLeet(char c) {
|
||||
return switch (c) {
|
||||
case '0' -> 'o';
|
||||
case '1' -> 'i';
|
||||
case '3' -> 'e';
|
||||
case '4' -> 'a';
|
||||
case '5' -> 's';
|
||||
case '7' -> 't';
|
||||
default -> c;
|
||||
};
|
||||
}
|
||||
|
||||
private static String collapseRepeatingLetters(String value) {
|
||||
if (value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
|
||||
StringBuilder collapsed = new StringBuilder(value.length());
|
||||
char prev = value.charAt(0);
|
||||
collapsed.append(prev);
|
||||
for (int i = 1; i < value.length(); i++) {
|
||||
char current = value.charAt(i);
|
||||
if (current != prev) {
|
||||
collapsed.append(current);
|
||||
prev = current;
|
||||
}
|
||||
}
|
||||
return collapsed.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
public record CreateListingReportRequest(
|
||||
@NotNull Long listingId,
|
||||
@NotBlank String reasonId,
|
||||
@NotBlank String reasonTitle,
|
||||
String details,
|
||||
List<String> attachmentNames,
|
||||
List<ListingReportAttachmentPayload> attachmentFiles
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
import jakarta.persistence.CollectionTable;
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.ElementCollection;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.FetchType;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Entity
|
||||
@Table(name = "listing_reports")
|
||||
public class ListingReport {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false)
|
||||
private Long listingId;
|
||||
|
||||
@Column(nullable = false, length = 180)
|
||||
private String listingTitle;
|
||||
|
||||
@Column(nullable = false, length = 120)
|
||||
private String listingCity;
|
||||
|
||||
@Column(length = 180)
|
||||
private String listingOwnerEmail;
|
||||
|
||||
@Column(nullable = false, length = 80)
|
||||
private String reasonId;
|
||||
|
||||
@Column(nullable = false, length = 180)
|
||||
private String reasonTitle;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String details;
|
||||
|
||||
@ElementCollection(fetch = FetchType.EAGER)
|
||||
@CollectionTable(name = "listing_report_attachments", joinColumns = @JoinColumn(name = "report_id"))
|
||||
private List<ListingReportAttachment> attachments = new ArrayList<>();
|
||||
|
||||
@Column(nullable = false, length = 180)
|
||||
private String reporterEmail;
|
||||
|
||||
@Column(length = 120)
|
||||
private String reporterName;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private ListingReportStatus status = ListingReportStatus.OPEN;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean listingDeleted = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
private Instant resolvedAt;
|
||||
|
||||
@Column(length = 180)
|
||||
private String resolvedByEmail;
|
||||
|
||||
@Column(length = 1000)
|
||||
private String resolutionNote;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public Long getListingId() {
|
||||
return listingId;
|
||||
}
|
||||
|
||||
public void setListingId(Long listingId) {
|
||||
this.listingId = listingId;
|
||||
}
|
||||
|
||||
public String getListingTitle() {
|
||||
return listingTitle;
|
||||
}
|
||||
|
||||
public void setListingTitle(String listingTitle) {
|
||||
this.listingTitle = listingTitle;
|
||||
}
|
||||
|
||||
public String getListingCity() {
|
||||
return listingCity;
|
||||
}
|
||||
|
||||
public void setListingCity(String listingCity) {
|
||||
this.listingCity = listingCity;
|
||||
}
|
||||
|
||||
public String getListingOwnerEmail() {
|
||||
return listingOwnerEmail;
|
||||
}
|
||||
|
||||
public void setListingOwnerEmail(String listingOwnerEmail) {
|
||||
this.listingOwnerEmail = listingOwnerEmail;
|
||||
}
|
||||
|
||||
public String getReasonId() {
|
||||
return reasonId;
|
||||
}
|
||||
|
||||
public void setReasonId(String reasonId) {
|
||||
this.reasonId = reasonId;
|
||||
}
|
||||
|
||||
public String getReasonTitle() {
|
||||
return reasonTitle;
|
||||
}
|
||||
|
||||
public void setReasonTitle(String reasonTitle) {
|
||||
this.reasonTitle = reasonTitle;
|
||||
}
|
||||
|
||||
public String getDetails() {
|
||||
return details;
|
||||
}
|
||||
|
||||
public void setDetails(String details) {
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
public List<ListingReportAttachment> getAttachments() {
|
||||
return attachments;
|
||||
}
|
||||
|
||||
public void setAttachments(List<ListingReportAttachment> attachments) {
|
||||
this.attachments = attachments == null ? new ArrayList<>() : new ArrayList<>(attachments);
|
||||
}
|
||||
|
||||
public String getReporterEmail() {
|
||||
return reporterEmail;
|
||||
}
|
||||
|
||||
public void setReporterEmail(String reporterEmail) {
|
||||
this.reporterEmail = reporterEmail;
|
||||
}
|
||||
|
||||
public String getReporterName() {
|
||||
return reporterName;
|
||||
}
|
||||
|
||||
public void setReporterName(String reporterName) {
|
||||
this.reporterName = reporterName;
|
||||
}
|
||||
|
||||
public ListingReportStatus getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(ListingReportStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public boolean isListingDeleted() {
|
||||
return listingDeleted;
|
||||
}
|
||||
|
||||
public void setListingDeleted(boolean listingDeleted) {
|
||||
this.listingDeleted = listingDeleted;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public Instant getResolvedAt() {
|
||||
return resolvedAt;
|
||||
}
|
||||
|
||||
public void setResolvedAt(Instant resolvedAt) {
|
||||
this.resolvedAt = resolvedAt;
|
||||
}
|
||||
|
||||
public String getResolvedByEmail() {
|
||||
return resolvedByEmail;
|
||||
}
|
||||
|
||||
public void setResolvedByEmail(String resolvedByEmail) {
|
||||
this.resolvedByEmail = resolvedByEmail;
|
||||
}
|
||||
|
||||
public String getResolutionNote() {
|
||||
return resolutionNote;
|
||||
}
|
||||
|
||||
public void setResolutionNote(String resolutionNote) {
|
||||
this.resolutionNote = resolutionNote;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Embeddable;
|
||||
|
||||
@Embeddable
|
||||
public class ListingReportAttachment {
|
||||
|
||||
@Column(name = "file_name", nullable = false, length = 255)
|
||||
private String fileName;
|
||||
|
||||
@Column(name = "file_type", length = 120)
|
||||
private String fileType;
|
||||
|
||||
@Column(name = "data_url", columnDefinition = "TEXT")
|
||||
private String dataUrl;
|
||||
|
||||
public String getFileName() {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public void setFileName(String fileName) {
|
||||
this.fileName = fileName;
|
||||
}
|
||||
|
||||
public String getFileType() {
|
||||
return fileType;
|
||||
}
|
||||
|
||||
public void setFileType(String fileType) {
|
||||
this.fileType = fileType;
|
||||
}
|
||||
|
||||
public String getDataUrl() {
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
public void setDataUrl(String dataUrl) {
|
||||
this.dataUrl = dataUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
public record ListingReportAttachmentPayload(
|
||||
String fileName,
|
||||
String fileType,
|
||||
String dataUrl
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
public record ListingReportAttachmentResponse(
|
||||
String fileName,
|
||||
String fileType,
|
||||
String dataUrl
|
||||
) {
|
||||
public static ListingReportAttachmentResponse from(ListingReportAttachment attachment) {
|
||||
return new ListingReportAttachmentResponse(
|
||||
attachment.getFileName(),
|
||||
attachment.getFileType(),
|
||||
attachment.getDataUrl()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/reports")
|
||||
public class ListingReportController {
|
||||
|
||||
private final ListingReportService listingReportService;
|
||||
|
||||
public ListingReportController(ListingReportService listingReportService) {
|
||||
this.listingReportService = listingReportService;
|
||||
}
|
||||
|
||||
@PostMapping("/listings")
|
||||
public ListingReportResponse createListingReport(
|
||||
@Valid @RequestBody CreateListingReportRequest request,
|
||||
Authentication authentication
|
||||
) {
|
||||
return listingReportService.create(request, authentication.getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface ListingReportRepository extends JpaRepository<ListingReport, Long> {
|
||||
|
||||
List<ListingReport> findAllByOrderByCreatedAtDesc();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record ListingReportResponse(
|
||||
Long id,
|
||||
Long listingId,
|
||||
String listingTitle,
|
||||
String listingCity,
|
||||
String listingOwnerEmail,
|
||||
String reasonId,
|
||||
String reasonTitle,
|
||||
String details,
|
||||
List<String> attachmentNames,
|
||||
List<ListingReportAttachmentResponse> attachments,
|
||||
String reporterEmail,
|
||||
String reporterName,
|
||||
ListingReportStatus status,
|
||||
boolean listingDeleted,
|
||||
Instant createdAt,
|
||||
Instant resolvedAt,
|
||||
String resolvedByEmail,
|
||||
String resolutionNote
|
||||
) {
|
||||
public static ListingReportResponse from(ListingReport report) {
|
||||
return new ListingReportResponse(
|
||||
report.getId(),
|
||||
report.getListingId(),
|
||||
report.getListingTitle(),
|
||||
report.getListingCity(),
|
||||
report.getListingOwnerEmail(),
|
||||
report.getReasonId(),
|
||||
report.getReasonTitle(),
|
||||
report.getDetails(),
|
||||
report.getAttachments().stream().map(ListingReportAttachment::getFileName).toList(),
|
||||
report.getAttachments().stream().map(ListingReportAttachmentResponse::from).toList(),
|
||||
report.getReporterEmail(),
|
||||
report.getReporterName(),
|
||||
report.getStatus(),
|
||||
report.isListingDeleted(),
|
||||
report.getCreatedAt(),
|
||||
report.getResolvedAt(),
|
||||
report.getResolvedByEmail(),
|
||||
report.getResolutionNote()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import pl.polskalokalnie.listing.ListingRepository;
|
||||
import pl.polskalokalnie.listing.PropertyListing;
|
||||
import pl.polskalokalnie.user.AppUser;
|
||||
import pl.polskalokalnie.user.UserRepository;
|
||||
|
||||
@Service
|
||||
public class ListingReportService {
|
||||
|
||||
private static final int MAX_ATTACHMENTS = 5;
|
||||
private static final int MAX_FILE_NAME_LENGTH = 255;
|
||||
private static final int MAX_FILE_TYPE_LENGTH = 120;
|
||||
private static final int MAX_DATA_URL_LENGTH = 16_000_000;
|
||||
|
||||
private final ListingReportRepository listingReportRepository;
|
||||
private final ListingRepository listingRepository;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public ListingReportService(
|
||||
ListingReportRepository listingReportRepository,
|
||||
ListingRepository listingRepository,
|
||||
UserRepository userRepository
|
||||
) {
|
||||
this.listingReportRepository = listingReportRepository;
|
||||
this.listingRepository = listingRepository;
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ListingReportResponse create(CreateListingReportRequest request, String reporterEmail) {
|
||||
PropertyListing listing = listingRepository.findById(request.listingId())
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Listing not found"));
|
||||
|
||||
ListingReport report = new ListingReport();
|
||||
report.setListingId(listing.getId());
|
||||
report.setListingTitle(listing.getTitle());
|
||||
report.setListingCity(listing.getCity());
|
||||
report.setListingOwnerEmail(listing.getOwnerEmail());
|
||||
report.setReasonId(request.reasonId().trim());
|
||||
report.setReasonTitle(request.reasonTitle().trim());
|
||||
report.setDetails(trimToNull(request.details()));
|
||||
report.setAttachments(cleanAttachments(request.attachmentNames(), request.attachmentFiles()));
|
||||
report.setReporterEmail(reporterEmail);
|
||||
report.setReporterName(userRepository.findByEmailIgnoreCase(reporterEmail).map(AppUser::getFullName).orElse(null));
|
||||
report.setStatus(ListingReportStatus.OPEN);
|
||||
|
||||
return ListingReportResponse.from(listingReportRepository.save(report));
|
||||
}
|
||||
|
||||
public List<ListingReportResponse> findAllForAdmin() {
|
||||
return listingReportRepository.findAllByOrderByCreatedAtDesc().stream()
|
||||
.map(ListingReportResponse::from)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ListingReportResponse resolve(Long reportId, String adminEmail, String note) {
|
||||
ListingReport report = requireReport(reportId);
|
||||
report.setStatus(ListingReportStatus.RESOLVED);
|
||||
report.setResolvedAt(Instant.now());
|
||||
report.setResolvedByEmail(adminEmail);
|
||||
report.setResolutionNote(trimToNull(note));
|
||||
return ListingReportResponse.from(listingReportRepository.save(report));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ListingReportResponse deleteListingAndResolve(Long reportId, String adminEmail) {
|
||||
ListingReport report = requireReport(reportId);
|
||||
Long listingId = report.getListingId();
|
||||
|
||||
if (listingId != null && listingRepository.existsById(listingId)) {
|
||||
listingRepository.deleteById(listingId);
|
||||
}
|
||||
|
||||
report.setListingDeleted(true);
|
||||
report.setStatus(ListingReportStatus.RESOLVED);
|
||||
report.setResolvedAt(Instant.now());
|
||||
report.setResolvedByEmail(adminEmail);
|
||||
if (report.getResolutionNote() == null || report.getResolutionNote().isBlank()) {
|
||||
report.setResolutionNote("Ogłoszenie usunięte przez administratora po zgłoszeniu.");
|
||||
}
|
||||
return ListingReportResponse.from(listingReportRepository.save(report));
|
||||
}
|
||||
|
||||
private ListingReport requireReport(Long id) {
|
||||
return listingReportRepository.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Report not found"));
|
||||
}
|
||||
|
||||
private static String trimToNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
|
||||
private static List<ListingReportAttachment> cleanAttachments(
|
||||
List<String> names,
|
||||
List<ListingReportAttachmentPayload> files
|
||||
) {
|
||||
List<ListingReportAttachment> cleaned = new ArrayList<>();
|
||||
|
||||
if (files != null) {
|
||||
for (ListingReportAttachmentPayload payload : files) {
|
||||
if (payload == null) {
|
||||
continue;
|
||||
}
|
||||
String fileName = truncate(trimToNull(payload.fileName()), MAX_FILE_NAME_LENGTH);
|
||||
if (fileName == null) {
|
||||
continue;
|
||||
}
|
||||
ListingReportAttachment attachment = new ListingReportAttachment();
|
||||
attachment.setFileName(fileName);
|
||||
attachment.setFileType(truncate(trimToNull(payload.fileType()), MAX_FILE_TYPE_LENGTH));
|
||||
String dataUrl = trimToNull(payload.dataUrl());
|
||||
if (dataUrl != null && dataUrl.length() > MAX_DATA_URL_LENGTH) {
|
||||
dataUrl = null;
|
||||
}
|
||||
attachment.setDataUrl(dataUrl);
|
||||
cleaned.add(attachment);
|
||||
if (cleaned.size() >= MAX_ATTACHMENTS) {
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (names != null) {
|
||||
for (String name : names) {
|
||||
String trimmed = truncate(trimToNull(name), MAX_FILE_NAME_LENGTH);
|
||||
if (trimmed == null) {
|
||||
continue;
|
||||
}
|
||||
boolean exists = cleaned.stream().anyMatch(item -> trimmed.equalsIgnoreCase(item.getFileName()));
|
||||
if (exists) {
|
||||
continue;
|
||||
}
|
||||
ListingReportAttachment attachment = new ListingReportAttachment();
|
||||
attachment.setFileName(trimmed);
|
||||
cleaned.add(attachment);
|
||||
if (cleaned.size() >= MAX_ATTACHMENTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
private static String truncate(String value, int maxLength) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.length() <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
return value.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
public enum ListingReportStatus {
|
||||
OPEN,
|
||||
RESOLVED
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.report;
|
||||
|
||||
public record ResolveListingReportRequest(
|
||||
String note
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package pl.polskalokalnie.settings;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
/**
|
||||
* Prosty magazyn flag konfiguracyjnych klucz-wartosc. Uzywany m.in. do oznaczenia, ze domyslny
|
||||
* slownik slow zakazanych zostal juz raz wsiany do bazy (dzieki czemu usuniecia sa trwale).
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "app_settings")
|
||||
public class AppSetting {
|
||||
|
||||
@Id
|
||||
@Column(name = "setting_key", length = 100)
|
||||
private String settingKey;
|
||||
|
||||
@Column(name = "setting_value", nullable = false, length = 255)
|
||||
private String settingValue;
|
||||
|
||||
protected AppSetting() {
|
||||
}
|
||||
|
||||
public AppSetting(String settingKey, String settingValue) {
|
||||
this.settingKey = settingKey;
|
||||
this.settingValue = settingValue;
|
||||
}
|
||||
|
||||
public String getSettingKey() {
|
||||
return settingKey;
|
||||
}
|
||||
|
||||
public String getSettingValue() {
|
||||
return settingValue;
|
||||
}
|
||||
|
||||
public void setSettingValue(String settingValue) {
|
||||
this.settingValue = settingValue;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.settings;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface AppSettingRepository extends JpaRepository<AppSetting, String> {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
public enum AccountType {
|
||||
PERSONAL,
|
||||
COMPANY
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import org.hibernate.annotations.ColumnDefault;
|
||||
|
||||
@Entity
|
||||
@Table(name = "app_users")
|
||||
public class AppUser {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 180)
|
||||
private String email;
|
||||
|
||||
@Column(length = 120)
|
||||
private String fullName;
|
||||
|
||||
// Nullable: konta zakladane przez logowanie spoleczne nie maja lokalnego hasla.
|
||||
@Column(length = 100)
|
||||
private String passwordHash;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private Role role = Role.USER;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
private AuthProvider provider = AuthProvider.LOCAL;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
@ColumnDefault("'PERSONAL'")
|
||||
private AccountType accountType = AccountType.PERSONAL;
|
||||
|
||||
@Column(length = 30)
|
||||
private String phone;
|
||||
|
||||
@Column(length = 255)
|
||||
private String address;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 20)
|
||||
@ColumnDefault("'EMAIL_AND_PHONE'")
|
||||
private ContactPreference contactPreference = ContactPreference.EMAIL_AND_PHONE;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 10)
|
||||
@ColumnDefault("'PL'")
|
||||
private PreferredLanguage preferredLanguage = PreferredLanguage.PL;
|
||||
|
||||
@Column(length = 20)
|
||||
private String nip;
|
||||
|
||||
private LocalDate birthDate;
|
||||
|
||||
// Konta zakladane przez rejestracje lokalna czekaja na zatwierdzenie przez administratora.
|
||||
@Column(nullable = false)
|
||||
@ColumnDefault("false")
|
||||
private boolean verified = false;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean blocked = false;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getPasswordHash() {
|
||||
return passwordHash;
|
||||
}
|
||||
|
||||
public void setPasswordHash(String passwordHash) {
|
||||
this.passwordHash = passwordHash;
|
||||
}
|
||||
|
||||
public Role getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(Role role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public AuthProvider getProvider() {
|
||||
return provider;
|
||||
}
|
||||
|
||||
public void setProvider(AuthProvider provider) {
|
||||
this.provider = provider;
|
||||
}
|
||||
|
||||
public AccountType getAccountType() {
|
||||
return accountType;
|
||||
}
|
||||
|
||||
public void setAccountType(AccountType accountType) {
|
||||
this.accountType = accountType;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
|
||||
public void setPhone(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getAddress() {
|
||||
return address;
|
||||
}
|
||||
|
||||
public void setAddress(String address) {
|
||||
this.address = address;
|
||||
}
|
||||
|
||||
public ContactPreference getContactPreference() {
|
||||
return contactPreference;
|
||||
}
|
||||
|
||||
public void setContactPreference(ContactPreference contactPreference) {
|
||||
this.contactPreference = contactPreference;
|
||||
}
|
||||
|
||||
public PreferredLanguage getPreferredLanguage() {
|
||||
return preferredLanguage;
|
||||
}
|
||||
|
||||
public void setPreferredLanguage(PreferredLanguage preferredLanguage) {
|
||||
this.preferredLanguage = preferredLanguage;
|
||||
}
|
||||
|
||||
public String getNip() {
|
||||
return nip;
|
||||
}
|
||||
|
||||
public void setNip(String nip) {
|
||||
this.nip = nip;
|
||||
}
|
||||
|
||||
public LocalDate getBirthDate() {
|
||||
return birthDate;
|
||||
}
|
||||
|
||||
public void setBirthDate(LocalDate birthDate) {
|
||||
this.birthDate = birthDate;
|
||||
}
|
||||
|
||||
public boolean isVerified() {
|
||||
return verified;
|
||||
}
|
||||
|
||||
public void setVerified(boolean verified) {
|
||||
this.verified = verified;
|
||||
}
|
||||
|
||||
public boolean isBlocked() {
|
||||
return blocked;
|
||||
}
|
||||
|
||||
public void setBlocked(boolean blocked) {
|
||||
this.blocked = blocked;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
public enum AuthProvider {
|
||||
LOCAL,
|
||||
GOOGLE,
|
||||
FACEBOOK
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.Table;
|
||||
import java.time.Instant;
|
||||
|
||||
// Adresy e-mail odrzuconych podczas weryfikacji kont - trwale zablokowane przed ponowna rejestracja.
|
||||
@Entity
|
||||
@Table(name = "blocked_emails")
|
||||
public class BlockedEmail {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, unique = true, length = 180)
|
||||
private String email;
|
||||
|
||||
@Column(nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
if (createdAt == null) {
|
||||
createdAt = Instant.now();
|
||||
}
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public String getEmail() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public void setEmail(String email) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
public Instant getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface BlockedEmailRepository extends JpaRepository<BlockedEmail, Long> {
|
||||
|
||||
boolean existsByEmailIgnoreCase(String email);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
public enum ContactPreference {
|
||||
EMAIL,
|
||||
PHONE,
|
||||
EMAIL_AND_PHONE
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
public enum PreferredLanguage {
|
||||
PL,
|
||||
EN,
|
||||
UK,
|
||||
DE
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
public enum Role {
|
||||
USER,
|
||||
ADMIN
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package pl.polskalokalnie.user;
|
||||
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface UserRepository extends JpaRepository<AppUser, Long> {
|
||||
|
||||
Optional<AppUser> findByEmailIgnoreCase(String email);
|
||||
|
||||
boolean existsByEmailIgnoreCase(String email);
|
||||
|
||||
Optional<AppUser> findFirstByRole(Role role);
|
||||
}
|
||||
Reference in New Issue
Block a user