Initial commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
backend/target/
|
||||
.git/
|
||||
@@ -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
@@ -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 |
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
target/
|
||||
.mvn/
|
||||
*.log
|
||||
@@ -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"]
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.log
|
||||
@@ -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>
|
||||
Generated
+1296
File diff suppressed because it is too large
Load Diff
@@ -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
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 |
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
Vendored
+9
@@ -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;
|
||||
}
|
||||
+23240
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
Reference in New Issue
Block a user