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
+4
View File
@@ -0,0 +1,4 @@
frontend/node_modules/
frontend/dist/
backend/target/
.git/
+11
View File
@@ -0,0 +1,11 @@
POSTGRES_DB=polskalokalnie
POSTGRES_USER=polskalokalnie
POSTGRES_PASSWORD=change_me_for_local_dev
SPRING_JPA_HIBERNATE_DDL_AUTO=update
# Local network binding.
# Keep database and backend local; expose only the web proxy to the LAN.
WEB_BIND_ADDRESS=0.0.0.0
BACKEND_BIND_ADDRESS=127.0.0.1
POSTGRES_BIND_ADDRESS=127.0.0.1
+21
View File
@@ -0,0 +1,21 @@
.env
.DS_Store
Thumbs.db
# Java / Spring Boot
backend/target/
*.class
# Node / React
frontend/node_modules/
frontend/dist/
frontend/.vite/
# IDE
.idea/
.vscode/
*.iml
# Docker / logs
*.log
docker-data/
Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 KiB

+110
View File
@@ -0,0 +1,110 @@
# Repository Guidelines
## Project Structure & Module Organization
This repository is currently a clean workspace with no application source tree checked in. Keep new code organized by responsibility:
- `src/` for application code and reusable modules.
- `tests/` for automated tests that mirror the `src/` layout.
- `assets/` for static images, fonts, fixtures, or other non-code resources.
- `docs/` for architecture notes, setup guides, and operational runbooks.
Prefer small, focused modules. Place shared helpers near the code that uses them first; promote them to a common module only when multiple features need them.
## Build, Test, and Development Commands
No build system or package manifest is currently present. When tooling is added, document the canonical commands in the README and keep them stable. Recommended command names:
- `npm run dev`, `pnpm dev`, or equivalent: start the local development server.
- `npm test`, `pnpm test`, or equivalent: run the full test suite.
- `npm run lint` / `npm run format`: check and format code style.
- `npm run build`: produce a production-ready build.
Avoid adding ad hoc scripts that only work on one machine. Prefer cross-platform commands or document platform-specific requirements clearly.
## Coding Style & Naming Conventions
Follow the formatter and linter configured by the first language/toolchain added to the project. Until then, use 2-space indentation for web projects, 4-space indentation for Python, and meaningful names over abbreviations.
Use clear file names that describe purpose, for example `user-service.ts`, `map-view.tsx`, or `test_user_service.py`. Keep public APIs explicit and avoid large catch-all utility files.
## Testing Guidelines
Add tests alongside each new feature or bug fix. Test files should mirror the implementation name, such as `user-service.test.ts` for `src/user-service.ts` or `test_user_service.py` for `src/user_service.py`.
Prefer fast unit tests for business logic and a smaller number of integration tests for workflows. Include regression tests for any fixed bug.
## Commit & Pull Request Guidelines
Git history is not available in this workspace, so use a simple imperative commit style: `Add project scaffold`, `Fix login validation`, `Update map assets`.
Pull requests should include a short summary, testing performed, linked issues when applicable, and screenshots or screen recordings for user-facing UI changes. Keep PRs focused; split unrelated changes into separate submissions.
## Security & Configuration Tips
Do not commit secrets, local credentials, generated build output, or machine-specific configuration. Store required environment variables in an ignored `.env` file and document safe example values in `.env.example`.
## Behavioral Guidelines To Reduce Common LLM Coding Mistakes
Tradeoff: These guidelines bias toward caution over speed. For trivial tasks, use judgment.
### 1. Think Before Coding
Don't assume. Don't hide confusion. Surface tradeoffs.
Before implementing:
- State assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them instead of choosing silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop, name what is confusing, and ask.
### 2. Simplicity First
Minimum code that solves the problem. Nothing speculative.
- No features beyond what was asked.
- No abstractions for single-use code.
- No flexibility or configurability that was not requested.
- No error handling for impossible scenarios.
- If code is much longer than needed, rewrite it to be simpler.
Ask: Would a senior engineer say this is overcomplicated? If yes, simplify.
### 3. Surgical Changes
Touch only what you must. Clean up only your own mess.
When editing existing code:
- Do not improve adjacent code, comments, or formatting unless needed for the task.
- Do not refactor code that is not broken.
- Match existing style, even if you would do it differently.
- If unrelated dead code is noticed, mention it but do not delete it.
When changes create orphans:
- Remove imports, variables, or functions made unused by your own changes.
- Do not remove pre-existing dead code unless asked.
Test: Every changed line should trace directly to the request.
### 4. Goal-Driven Execution
Define success criteria. Loop until verified.
Transform tasks into verifiable goals:
- Add validation -> write tests for invalid inputs, then make them pass.
- Fix a bug -> write a test that reproduces it, then make it pass.
- Refactor X -> ensure tests pass before and after.
For multi-step tasks, state a brief plan:
1. [Step] -> verify: [check]
2. [Step] -> verify: [check]
3. [Step] -> verify: [check]
Strong success criteria enable independent execution. Weak criteria require frequent clarification.
These guidelines are working if there are fewer unnecessary diff changes, fewer rewrites caused by overcomplication, and clarifying questions appear before implementation mistakes.
+113
View File
@@ -0,0 +1,113 @@
# Polska Lokalnie
Aplikacja do ogloszen sprzedazy i wynajmu mieszkan, domow oraz apartamentow.
## Stack technologiczny
- Backend: Java 21, Spring Boot, Spring Web, Spring Data JPA, Bean Validation
- Frontend: React, TypeScript, Vite
- Baza danych: PostgreSQL
- Reverse proxy i statyczny frontend: Nginx
- Uruchamianie: Docker Compose
## Uruchomienie w Dockerze
Skopiuj konfiguracje srodowiskowa i uruchom caly stack:
```bash
cp .env.example .env
docker compose up --build
```
W PowerShell mozesz uzyc:
```powershell
Copy-Item .env.example .env
docker compose up --build
```
Aplikacja bedzie dostepna pod adresem:
- Frontend: http://localhost
- API: http://localhost/api/listings
- PostgreSQL: localhost:5432
## Dostep w sieci lokalnej
Aplikacja jest przygotowana do pracy z innych urzadzen w tej samej sieci Wi-Fi/LAN.
Najpierw sprawdz adres IP komputera, na ktorym uruchamiasz projekt:
```powershell
ipconfig
```
Znajdz aktywna karte sieciowa i pole `IPv4 Address`, na przyklad `192.168.1.25`.
### Docker Compose
Uruchom caly stack:
```powershell
docker compose up --build
```
Na drugim urzadzeniu w tej samej sieci wejdz w przegladarce na:
```text
http://192.168.1.25
```
Port `80` jest wystawiany na siec lokalna przez Nginx. Backend i baza danych domyslnie sa dostepne tylko lokalnie na komputerze hosta, a API jest osiagalne przez Nginx pod `/api`.
### Tryb developerski
Uruchom backend lokalnie:
```powershell
cd backend
mvn spring-boot:run
```
Uruchom frontend lokalnie:
```powershell
cd frontend
npm run dev
```
Na drugim urzadzeniu wejdz na:
```text
http://192.168.1.25:5173
```
Vite nasluchuje na `0.0.0.0`, a zapytania `/api` sa proxy do backendu na `127.0.0.1:8080`.
## Uruchomienie lokalne bez Dockera
Backend:
```bash
cd backend
mvn spring-boot:run
```
Frontend:
```bash
cd frontend
npm install
npm run dev
```
Vite przekierowuje zapytania `/api` do backendu na `http://localhost:8080`.
## Struktura projektu
```text
backend/ Spring Boot API
frontend/ React + Vite
nginx/ konfiguracja Nginx i obraz web
docker-compose.yml
```
+3
View File
@@ -0,0 +1,3 @@
target/
.mvn/
*.log
+16
View File
@@ -0,0 +1,16 @@
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
+6
View File
@@ -0,0 +1,6 @@
{
"name": "backend",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+83
View File
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>pl.polskalokalnie</groupId>
<artifactId>backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>polskalokalnie-backend</name>
<description>API ogloszen nieruchomosci Polska Lokalnie</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
@@ -0,0 +1,29 @@
server:
port: ${SERVER_PORT:8080}
error:
include-message: always
spring:
application:
name: polskalokalnie-backend
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/polskalokalnie}
username: ${SPRING_DATASOURCE_USERNAME:polskalokalnie}
password: ${SPRING_DATASOURCE_PASSWORD:polskalokalnie_dev}
jpa:
hibernate:
ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:update}
open-in-view: false
properties:
hibernate:
format_sql: true
app:
jwt:
# W produkcji ustaw APP_JWT_SECRET (min. 32 znaki) przez zmienna srodowiskowa.
secret: ${APP_JWT_SECRET:local-dev-secret-change-me-please-32chars-min}
expiration-seconds: ${APP_JWT_EXPIRATION_SECONDS:86400}
admin:
email: ${APP_ADMIN_EMAIL:admin@mieszko.pl}
password: ${APP_ADMIN_PASSWORD:Admin123!}
name: ${APP_ADMIN_NAME:Administrator Mieszko}
@@ -0,0 +1,12 @@
package pl.polskalokalnie;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class PolskaLokalnieApplicationTests {
@Test
void contextLoads() {
}
}
@@ -0,0 +1,18 @@
spring:
datasource:
url: jdbc:h2:mem:polskalokalnie;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create-drop
app:
jwt:
secret: test-secret-key-for-junit-context-32chars-min
expiration-seconds: 3600
admin:
email: admin@mieszko.pl
password: Admin123!
name: Administrator Mieszko
+42
View File
@@ -0,0 +1,42 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB:-polskalokalnie}
POSTGRES_USER: ${POSTGRES_USER:-polskalokalnie}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-polskalokalnie_dev}
ports:
- "${POSTGRES_BIND_ADDRESS:-127.0.0.1}:5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-polskalokalnie} -d ${POSTGRES_DB:-polskalokalnie}"]
interval: 10s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/${POSTGRES_DB:-polskalokalnie}
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-polskalokalnie}
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:-polskalokalnie_dev}
SPRING_JPA_HIBERNATE_DDL_AUTO: ${SPRING_JPA_HIBERNATE_DDL_AUTO:-update}
depends_on:
db:
condition: service_healthy
ports:
- "${BACKEND_BIND_ADDRESS:-127.0.0.1}:8080:8080"
web:
build:
context: .
dockerfile: nginx/Dockerfile
depends_on:
- backend
ports:
- "${WEB_BIND_ADDRESS:-0.0.0.0}:80:80"
volumes:
postgres-data:
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
*.log
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Polska Lokalnie</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1296
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "polskalokalnie-frontend",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc && vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@types/pdfmake": "^0.3.3",
"pdfmake": "^0.3.11",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
"typescript": "5.6.3",
"vite": "8.0.16"
}
}
+18542
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+193
View File
@@ -0,0 +1,193 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
export type Role = 'USER' | 'ADMIN';
export type AuthProviderName = 'LOCAL' | 'GOOGLE' | 'FACEBOOK';
export type AccountType = 'PERSONAL' | 'COMPANY';
export type ContactPreference = 'EMAIL' | 'PHONE' | 'EMAIL_AND_PHONE';
export type PreferredLanguage = 'PL' | 'EN' | 'UK' | 'DE';
export type AuthUser = {
id: number;
email: string;
fullName: string;
role: Role;
provider: AuthProviderName;
accountType: AccountType;
phone: string | null;
address: string | null;
contactPreference: ContactPreference;
preferredLanguage: PreferredLanguage;
nip: string | null;
birthDate: string | null;
verified: boolean;
blocked: boolean;
createdAt: string;
};
type AuthResponse = { token: string; user: AuthUser };
export type RegisterDetails = {
accountType: AccountType;
phone?: string;
nip?: string;
};
type AuthContextValue = {
user: AuthUser | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<AuthUser>;
register: (email: string, password: string, fullName: string, details?: RegisterDetails) => Promise<AuthUser>;
socialLogin: (provider: Exclude<AuthProviderName, 'LOCAL'>) => Promise<AuthUser>;
updateProfile: (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => Promise<AuthUser>;
logout: () => void;
};
const TOKEN_KEY = 'polskalokalnie-auth-token';
const API_BASE = '/api';
export function getToken(): string | null {
return window.localStorage.getItem(TOKEN_KEY);
}
/**
* Cienki wrapper na fetch: dokleja token Bearer i zamienia bledy backendu (pole "message")
* na wyjatek z czytelnym komunikatem po polsku.
*/
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers = new Headers(options.headers);
if (options.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!response.ok) {
let message = 'Wystąpił błąd. Spróbuj ponownie.';
try {
const data = await response.json();
if (data && typeof data.message === 'string' && data.message.trim()) {
message = data.message;
}
} catch {
// brak/niepoprawne cialo JSON - zostaje komunikat domyslny
}
const error = new Error(message) as Error & { status?: number };
error.status = response.status;
throw error;
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => getToken());
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState<boolean>(() => Boolean(getToken()));
// Po odswiezeniu strony: majac token w localStorage, pobierz aktualny profil.
useEffect(() => {
if (!token) {
setUser(null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
apiFetch<AuthUser>('/auth/me')
.then((me) => {
if (!cancelled) {
setUser(me);
setLoading(false);
}
})
.catch(() => {
if (!cancelled) {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [token]);
const applyAuth = useCallback((auth: AuthResponse) => {
window.localStorage.setItem(TOKEN_KEY, auth.token);
setToken(auth.token);
setUser(auth.user);
return auth.user;
}, []);
const login = useCallback(
async (email: string, password: string) =>
applyAuth(await apiFetch<AuthResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
})),
[applyAuth],
);
const register = useCallback(
async (email: string, password: string, fullName: string, details?: RegisterDetails) =>
applyAuth(await apiFetch<AuthResponse>('/auth/register', {
method: 'POST',
body: JSON.stringify({ email, password, fullName, ...details }),
})),
[applyAuth],
);
const socialLogin = useCallback(
async (provider: Exclude<AuthProviderName, 'LOCAL'>) =>
applyAuth(await apiFetch<AuthResponse>('/auth/social', {
method: 'POST',
body: JSON.stringify({ provider }),
})),
[applyAuth],
);
const updateProfile = useCallback(
async (fullName: string, phone?: string, birthDate?: string, address?: string, contactPreference?: ContactPreference, preferredLanguage?: PreferredLanguage) => {
const updated = await apiFetch<AuthUser>('/auth/me', {
method: 'PUT',
body: JSON.stringify({ fullName, phone, birthDate, address, contactPreference, preferredLanguage }),
});
setUser(updated);
return updated;
},
[],
);
const logout = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setUser(null);
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, token, loading, login, register, socialLogin, updateProfile, logout }),
[user, token, loading, login, register, socialLogin, updateProfile, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within an AuthProvider');
}
return ctx;
}
+251
View File
@@ -0,0 +1,251 @@
export type UiLanguage = 'PL' | 'EN' | 'UK' | 'DE';
const en: Record<string, string> = {
'Kupuję': 'Buy',
'Wynajmuję': 'Rent',
'Jak sprzedawać': 'How to sell',
'Wycena mieszkania': 'Property valuation',
'Firmy i usługi': 'Companies and services',
'Poradniki': 'Guides',
'Dane i ustawienia': 'Data and settings',
'Edytuj profil': 'Edit profile',
'Dodatkowe informacje': 'Additional information',
'Adres zamieszkania': 'Residential address',
'Preferencje kontaktu': 'Contact preferences',
'Język komunikacji': 'Communication language',
'Nie podano': 'Not provided',
'Polski': 'Polish',
'Zapisz zmiany': 'Save changes',
'Dodaj zdjęcie': 'Add photo',
'Zmień zdjęcie': 'Change photo',
'Usuń zdjęcie': 'Remove photo',
'Personalizacja profilu': 'Profile personalization',
'Widoczność profilu': 'Profile visibility',
'Publiczna': 'Public',
'Prywatna': 'Private',
'Tylko kontakty': 'Contacts only',
'Profil publiczny': 'Public profile',
'Zobacz profil': 'View profile',
'Usunięcie konta': 'Account deletion',
'Usuń konto': 'Delete account',
'Porównanie mieszkań': 'Apartment comparison',
'Porównaj do 4 mieszkań i wybierz najlepszą ofertę dla siebie.': 'Compare up to 4 apartments and choose the best option for you.',
'Dodaj mieszkania do porównania': 'Add apartments to compare',
'Możesz porównać do 4 ofert jednocześnie.': 'You can compare up to 4 listings at once.',
'Szukaj mieszkań': 'Search apartments',
'Wszystkie typy': 'All types',
'Mieszkanie': 'Apartment',
'Dom': 'House',
'Działka': 'Plot',
'Lokal użytkowy': 'Commercial unit',
'Pokój': 'Room',
'Dodaj': 'Add',
'Podsumowanie porównania': 'Comparison summary',
'Cena': 'Price',
'Cena całkowita': 'Total price',
'Cena za m²': 'Price per m²',
'Przelicznik ceny do metrażu': 'Price-to-area ratio',
'Lokalizacja': 'Location',
'Lokalizacja ogłoszenia': 'Listing location',
'Metraż': 'Area',
'Powierzchnia użytkowa': 'Usable area',
'Liczba pokoi': 'Number of rooms',
'Ilość pomieszczeń': 'Number of spaces',
'Piętro': 'Floor',
'Poziom mieszkania': 'Apartment floor',
'Stan': 'Condition',
'Stan techniczny': 'Technical condition',
'Rok budowy': 'Year built',
'Rok oddania do użytku': 'Year commissioned',
'Czynsz': 'Rent',
'Miesięczny czynsz': 'Monthly rent',
'Parking': 'Parking',
'Miejsce parkingowe': 'Parking space',
'Balkon / Taras': 'Balcony / Terrace',
'Dodatkowa przestrzeń': 'Additional space',
'Dostępność': 'Availability',
'Termin dostępności': 'Availability date',
'Ocena ogólna': 'Overall score',
'Na podstawie kluczowych czynników': 'Based on key factors',
'Brak wybranych ogłoszeń': 'No selected listings',
'Dodaj pierwsze mieszkanie, aby rozpocząć porównanie.': 'Add your first apartment to start comparison.',
'Zobacz ogłoszenie': 'View listing',
'Wczytuję ogłoszenia...': 'Loading listings...',
'Udostępnij porównanie': 'Share comparison',
'Wyczyść wszystko': 'Clear all',
'Strona główna': 'Home page',
'Porównywarka mieszkań': 'Apartment comparison tool',
'Sprzedaż': 'Sale',
'Wynajem': 'Rent',
'Dodaj min. 2 oferty do porównania': 'Add at least 2 listings to compare',
'Świetna': 'Excellent',
'Bardzo dobra': 'Very good',
'Dobra': 'Good',
'Do poprawy': 'Needs improvement',
'Od zaraz': 'Immediately',
'Do uzgodnienia': 'To be agreed',
'Brak': 'None',
};
const uk: Record<string, string> = {
'Kupuję': 'Купую',
'Wynajmuję': 'Орендую',
'Jak sprzedawać': 'Як продавати',
'Wycena mieszkania': 'Оцінка нерухомості',
'Firmy i usługi': 'Компанії та послуги',
'Poradniki': 'Порадники',
'Dane i ustawienia': 'Дані та налаштування',
'Edytuj profil': 'Редагувати профіль',
'Dodatkowe informacje': 'Додаткова інформація',
'Adres zamieszkania': 'Адреса проживання',
'Preferencje kontaktu': 'Параметри контакту',
'Język komunikacji': 'Мова спілкування',
'Nie podano': 'Не вказано',
'Polski': 'Польська',
'Zapisz zmiany': 'Зберегти зміни',
'Dodaj zdjęcie': 'Додати фото',
'Zmień zdjęcie': 'Змінити фото',
'Usuń zdjęcie': 'Видалити фото',
'Personalizacja profilu': 'Персоналізація профілю',
'Widoczność profilu': 'Видимість профілю',
'Publiczna': 'Публічний',
'Prywatna': 'Приватний',
'Tylko kontakty': 'Лише контакти',
'Profil publiczny': 'Публічний профіль',
'Zobacz profil': 'Переглянути профіль',
'Usunięcie konta': 'Видалення облікового запису',
'Usuń konto': 'Видалити обліковий запис',
'Porównanie mieszkań': 'Порівняння квартир',
'Porównaj do 4 mieszkań i wybierz najlepszą ofertę dla siebie.': 'Порівняйте до 4 квартир і оберіть найкращу пропозицію для себе.',
'Dodaj mieszkania do porównania': 'Додайте квартири для порівняння',
'Możesz porównać do 4 ofert jednocześnie.': 'Ви можете порівняти до 4 оголошень одночасно.',
'Szukaj mieszkań': 'Пошук квартир',
'Wszystkie typy': 'Усі типи',
'Mieszkanie': 'Квартира',
'Dom': 'Будинок',
'Działka': 'Ділянка',
'Lokal użytkowy': 'Комерційне приміщення',
'Pokój': 'Кімната',
'Dodaj': 'Додати',
'Podsumowanie porównania': 'Підсумок порівняння',
'Cena': 'Ціна',
'Cena całkowita': 'Загальна ціна',
'Cena za m²': 'Ціна за м²',
'Lokalizacja': 'Локація',
'Metraż': 'Площа',
'Liczba pokoi': 'Кількість кімнат',
'Piętro': 'Поверх',
'Stan': 'Стан',
'Rok budowy': 'Рік побудови',
'Czynsz': 'Орендна плата',
'Parking': 'Паркування',
'Balkon / Taras': 'Балкон / Тераса',
'Dostępność': 'Доступність',
'Ocena ogólna': 'Загальна оцінка',
'Brak wybranych ogłoszeń': 'Немає вибраних оголошень',
'Zobacz ogłoszenie': 'Переглянути оголошення',
'Udostępnij porównanie': 'Поділитися порівнянням',
'Wyczyść wszystko': 'Очистити все',
'Strona główna': 'Головна сторінка',
'Porównywarka mieszkań': 'Порівняння квартир',
'Sprzedaż': 'Продаж',
'Wynajem': 'Оренда',
'Brak': 'Немає',
};
const de: Record<string, string> = {
'Kupuję': 'Ich kaufe',
'Wynajmuję': 'Ich miete',
'Jak sprzedawać': 'Wie verkauft man',
'Wycena mieszkania': 'Immobilienbewertung',
'Firmy i usługi': 'Firmen und Dienstleistungen',
'Poradniki': 'Ratgeber',
'Dane i ustawienia': 'Daten und Einstellungen',
'Edytuj profil': 'Profil bearbeiten',
'Dodatkowe informacje': 'Zusätzliche Informationen',
'Adres zamieszkania': 'Wohnadresse',
'Preferencje kontaktu': 'Kontaktpräferenzen',
'Język komunikacji': 'Kommunikationssprache',
'Nie podano': 'Nicht angegeben',
'Polski': 'Polnisch',
'Zapisz zmiany': 'Änderungen speichern',
'Dodaj zdjęcie': 'Foto hinzufügen',
'Zmień zdjęcie': 'Foto ändern',
'Usuń zdjęcie': 'Foto entfernen',
'Personalizacja profilu': 'Profilpersonalisierung',
'Widoczność profilu': 'Profilsichtbarkeit',
'Publiczna': 'Öffentlich',
'Prywatna': 'Privat',
'Tylko kontakty': 'Nur Kontakte',
'Profil publiczny': 'Öffentliches Profil',
'Zobacz profil': 'Profil ansehen',
'Usunięcie konta': 'Konto löschen',
'Usuń konto': 'Konto entfernen',
'Porównanie mieszkań': 'Wohnungsvergleich',
'Porównaj do 4 mieszkań i wybierz najlepszą ofertę dla siebie.': 'Vergleichen Sie bis zu 4 Wohnungen und wählen Sie das beste Angebot.',
'Dodaj mieszkania do porównania': 'Wohnungen zum Vergleich hinzufügen',
'Możesz porównać do 4 ofert jednocześnie.': 'Sie können bis zu 4 Angebote gleichzeitig vergleichen.',
'Szukaj mieszkań': 'Wohnungen suchen',
'Wszystkie typy': 'Alle Typen',
'Mieszkanie': 'Wohnung',
'Dom': 'Haus',
'Działka': 'Grundstück',
'Lokal użytkowy': 'Gewerbeobjekt',
'Pokój': 'Zimmer',
'Dodaj': 'Hinzufügen',
'Podsumowanie porównania': 'Vergleichszusammenfassung',
'Cena': 'Preis',
'Cena całkowita': 'Gesamtpreis',
'Cena za m²': 'Preis pro m²',
'Lokalizacja': 'Standort',
'Metraż': 'Fläche',
'Liczba pokoi': 'Zimmeranzahl',
'Piętro': 'Etage',
'Stan': 'Zustand',
'Rok budowy': 'Baujahr',
'Czynsz': 'Miete',
'Parking': 'Parken',
'Balkon / Taras': 'Balkon / Terrasse',
'Dostępność': 'Verfügbarkeit',
'Ocena ogólna': 'Gesamtbewertung',
'Brak wybranych ogłoszeń': 'Keine ausgewählten Anzeigen',
'Zobacz ogłoszenie': 'Anzeige ansehen',
'Udostępnij porównanie': 'Vergleich teilen',
'Wyczyść wszystko': 'Alles löschen',
'Strona główna': 'Startseite',
'Porównywarka mieszkań': 'Wohnungsvergleich',
'Sprzedaż': 'Verkauf',
'Wynajem': 'Miete',
'Brak': 'Keine',
};
const dictionaries: Record<Exclude<UiLanguage, 'PL'>, Record<string, string>> = {
EN: en,
UK: uk,
DE: de,
};
function normalizeKey(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
export function getManualTranslation(language: UiLanguage, source: string): string | null {
if (language === 'PL') {
return source;
}
const dictionary = dictionaries[language];
if (!dictionary) {
return null;
}
const exact = dictionary[source];
if (exact) {
return exact;
}
const normalizedSource = normalizeKey(source);
if (!normalizedSource) {
return null;
}
const normalizedHit = dictionary[normalizedSource];
return normalizedHit ?? null;
}
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import { AuthProvider } from './auth';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</React.StrictMode>,
);
+9
View File
@@ -0,0 +1,9 @@
declare module 'pdfmake/build/fonts/Roboto.js' {
type PdfMakeFontContainer = {
vfs: Record<string, { data: string; encoding?: string } | string>;
fonts: Record<string, Record<string, string>>;
};
const fontContainer: PdfMakeFontContainer;
export default fontContainer;
}
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 5173,
strictPort: true,
allowedHosts: true,
hmr: {
overlay: false,
},
watch: {
usePolling: true,
interval: 120,
awaitWriteFinish: {
stabilityThreshold: 260,
pollInterval: 80,
},
},
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
preview: {
host: '0.0.0.0',
port: 4173,
strictPort: true,
allowedHosts: true,
},
});
+15
View File
@@ -0,0 +1,15 @@
FROM node:22-alpine AS frontend-build
WORKDIR /app
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY --from=frontend-build /app/dist /usr/share/nginx/html
EXPOSE 80
+20
View File
@@ -0,0 +1,20 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8080/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}