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