Initial commit

This commit is contained in:
2026-07-09 00:14:45 +02:00
commit 0c2ec3fc1c
100 changed files with 47687 additions and 0 deletions
@@ -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);
}