firmy i usługi plus ustawiaenia

This commit is contained in:
2026-08-19 17:02:55 +02:00
parent c52bbabec0
commit f9f030ba14
21 changed files with 3438 additions and 426 deletions
@@ -5,6 +5,7 @@ import java.util.List;
import java.security.SecureRandom;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -33,6 +34,8 @@ import pl.polskalokalnie.report.ListingReportService;
import pl.polskalokalnie.report.ResolveListingReportRequest;
import pl.polskalokalnie.report.UserReportResponse;
import pl.polskalokalnie.report.UserReportService;
import pl.polskalokalnie.lead.AdvertisingEnquiryResponse;
import pl.polskalokalnie.lead.AdvertisingEnquiryService;
import pl.polskalokalnie.support.SupportMessageResponse;
import pl.polskalokalnie.support.SupportMessageService;
import pl.polskalokalnie.user.AppUser;
@@ -55,6 +58,7 @@ public class AdminController {
private final ListingReportService listingReportService;
private final UserReportService userReportService;
private final SupportMessageService supportMessageService;
private final AdvertisingEnquiryService advertisingEnquiryService;
private final MessageService messageService;
private final TextModerationService textModerationService;
private final AdminStatsService adminStatsService;
@@ -65,6 +69,7 @@ public class AdminController {
ListingService listingService, ListingReportService listingReportService,
UserReportService userReportService,
SupportMessageService supportMessageService,
AdvertisingEnquiryService advertisingEnquiryService,
MessageService messageService,
TextModerationService textModerationService,
AdminStatsService adminStatsService,
@@ -76,6 +81,7 @@ public class AdminController {
this.listingReportService = listingReportService;
this.userReportService = userReportService;
this.supportMessageService = supportMessageService;
this.advertisingEnquiryService = advertisingEnquiryService;
this.messageService = messageService;
this.textModerationService = textModerationService;
this.adminStatsService = adminStatsService;
@@ -310,6 +316,28 @@ public class AdminController {
return supportMessageService.setHandled(id, handled, authentication.getName());
}
// --- Zgloszenia z formularza reklamowego (/reklama) ---
@GetMapping("/advertising-enquiries")
public List<AdvertisingEnquiryResponse> advertisingEnquiries() {
return advertisingEnquiryService.findAllForAdmin();
}
@PostMapping("/advertising-enquiries/{id}/handled")
public AdvertisingEnquiryResponse setAdvertisingEnquiryHandled(
@PathVariable Long id,
@RequestParam(defaultValue = "true") boolean handled,
Authentication authentication
) {
return advertisingEnquiryService.setHandled(id, handled, authentication.getName());
}
@DeleteMapping("/advertising-enquiries/{id}")
public ResponseEntity<Void> deleteAdvertisingEnquiry(@PathVariable Long id) {
advertisingEnquiryService.delete(id);
return ResponseEntity.noContent().build();
}
// --- Slowa zabronione ---
@GetMapping("/forbidden-words")
@@ -40,6 +40,8 @@ public class SecurityConfig {
"/api/auth/forgot-password", "/api/auth/reset-password",
"/api/auth/verify-phone", "/api/auth/resend-phone-otp").permitAll()
.requestMatchers("/api/i18n/translate").permitAll()
.requestMatchers(HttpMethod.GET, "/api/fx/rates").permitAll()
.requestMatchers(HttpMethod.POST, "/api/advertising-enquiries").permitAll()
.requestMatchers(HttpMethod.GET, "/api/gus/company").permitAll()
.requestMatchers(HttpMethod.GET, "/api/unsubscribe").permitAll()
.requestMatchers("/api/auth/me").authenticated()
@@ -0,0 +1,25 @@
package pl.polskalokalnie.fx;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Kursy walut dla frontendu. Endpoint jest publiczny, bo ceny widza takze niezalogowani -
* odpowiedz nie zawiera zadnych danych uzytkownika.
*/
@RestController
@RequestMapping("/api/fx")
public class FxController {
private final FxRateService fxRateService;
public FxController(FxRateService fxRateService) {
this.fxRateService = fxRateService;
}
@GetMapping("/rates")
public FxRatesResponse rates() {
return fxRateService.currentRates();
}
}
@@ -0,0 +1,161 @@
package pl.polskalokalnie.fx;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import pl.polskalokalnie.settings.AppSetting;
import pl.polskalokalnie.settings.AppSettingRepository;
/**
* Kursy walut dla ustawienia "Waluta" z /konto/ustawienia.
*
* Zrodlem jest tabela A NBP (kursy srednie), odpytywana najwyzej raz na REFRESH_INTERVAL.
* Ostatnia udana odpowiedz laduje w app_settings, wiec po restarcie aplikacji nadal mamy realny
* kurs, nawet jesli NBP akurat nie odpowiada. Dopiero gdy i tego nie ma, wchodza wartosci zapasowe -
* sa przyblizone i sluza wylacznie temu, zeby interfejs nie pokazywal pustych cen.
*/
@Service
public class FxRateService {
private static final Logger log = LoggerFactory.getLogger(FxRateService.class);
private static final String NBP_TABLE_A_URL = "https://api.nbp.pl/api/exchangerates/tables/A?format=json";
private static final Duration REFRESH_INTERVAL = Duration.ofHours(6);
private static final String CACHE_KEY = "fx.nbp.table-a";
/** Waluty, ktore uzytkownik moze wybrac (zgodnie z enumem Currency). */
private static final String[] SUPPORTED = {"EUR", "USD"};
/** Awaryjne kursy uzywane tylko wtedy, gdy NBP nie odpowiedzialo i nie mamy nic w bazie. */
private static final Map<String, BigDecimal> FALLBACK_RATES = Map.of(
"EUR", new BigDecimal("4.30"),
"USD", new BigDecimal("3.95"));
private final AppSettingRepository appSettingRepository;
private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(6))
.build();
private volatile FxRatesResponse cached;
private volatile Instant fetchedAt;
public FxRateService(AppSettingRepository appSettingRepository) {
this.appSettingRepository = appSettingRepository;
}
public FxRatesResponse currentRates() {
FxRatesResponse snapshot = cached;
if (snapshot != null && fetchedAt != null
&& Duration.between(fetchedAt, Instant.now()).compareTo(REFRESH_INTERVAL) < 0) {
return snapshot;
}
return refresh();
}
private synchronized FxRatesResponse refresh() {
try {
FxRatesResponse fresh = fetchFromNbp();
cached = fresh;
fetchedAt = Instant.now();
store(fresh);
return fresh;
} catch (Exception ex) {
log.warn("Nie udalo sie pobrac kursow z NBP: {}", ex.getMessage());
// Kolejna probe podejmiemy dopiero po REFRESH_INTERVAL, zeby nie dobijac sie do NBP przy kazdym zadaniu.
FxRatesResponse fallback = cached != null ? cached : readStored();
cached = fallback;
fetchedAt = Instant.now();
return fallback;
}
}
private FxRatesResponse fetchFromNbp() throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(NBP_TABLE_A_URL))
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("NBP odpowiedzialo kodem " + response.statusCode());
}
JsonNode table = objectMapper.readTree(response.body());
if (!table.isArray() || table.isEmpty()) {
throw new IllegalStateException("Nieoczekiwany format odpowiedzi NBP");
}
JsonNode first = table.get(0);
String effectiveDate = first.path("effectiveDate").asText("");
Map<String, BigDecimal> rates = baseRates();
for (JsonNode rate : first.path("rates")) {
String code = rate.path("code").asText("");
for (String supported : SUPPORTED) {
if (supported.equals(code)) {
rates.put(code, rate.path("mid").decimalValue());
}
}
}
if (rates.size() != SUPPORTED.length + 1) {
throw new IllegalStateException("Brak kompletu kursow w tabeli NBP");
}
return new FxRatesResponse("PLN", rates, effectiveDate, "NBP");
}
/** Zapis ostatnich udanych kursow, zeby przetrwaly restart aplikacji. */
private void store(FxRatesResponse rates) {
try {
String payload = objectMapper.writeValueAsString(rates);
AppSetting setting = appSettingRepository.findById(CACHE_KEY).orElse(null);
if (setting == null) {
appSettingRepository.save(new AppSetting(CACHE_KEY, payload));
} else {
setting.setSettingValue(payload);
appSettingRepository.save(setting);
}
} catch (Exception ex) {
log.warn("Nie udalo sie zapisac kursow walut: {}", ex.getMessage());
}
}
private FxRatesResponse readStored() {
try {
AppSetting setting = appSettingRepository.findById(CACHE_KEY).orElse(null);
if (setting != null) {
FxRatesResponse stored = objectMapper.readValue(setting.getSettingValue(), FxRatesResponse.class);
if (stored.rates() != null && !stored.rates().isEmpty()) {
return stored;
}
}
} catch (Exception ex) {
log.warn("Nie udalo sie odczytac zapisanych kursow: {}", ex.getMessage());
}
return fallback();
}
private static FxRatesResponse fallback() {
Map<String, BigDecimal> rates = baseRates();
rates.putAll(FALLBACK_RATES);
return new FxRatesResponse("PLN", rates, "", "FALLBACK");
}
private static Map<String, BigDecimal> baseRates() {
Map<String, BigDecimal> rates = new LinkedHashMap<>();
rates.put("PLN", BigDecimal.ONE);
return rates;
}
}
@@ -0,0 +1,21 @@
package pl.polskalokalnie.fx;
import java.math.BigDecimal;
import java.util.Map;
/**
* Kursy przeliczeniowe dla cen prezentowanych uzytkownikowi.
*
* Ceny w bazie sa trzymane w zlotowkach, wiec PLN jest waluta bazowa. Kazdy wpis w `rates` mowi,
* ile zlotowek kosztuje jedna jednostka danej waluty (tak samo jak kurs sredni NBP z tabeli A),
* czyli przeliczenie PLN -> waluta to dzielenie przez ten kurs.
*
* @param source `NBP` gdy kursy pochodza z api.nbp.pl, `FALLBACK` gdy uzyto wartosci zapasowych
*/
public record FxRatesResponse(
String base,
Map<String, BigDecimal> rates,
String effectiveDate,
String source
) {
}
@@ -0,0 +1,141 @@
package pl.polskalokalnie.lead;
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;
/**
* Pojedyncze zgloszenie z formularza "Zainteresowany reklama?" na stronie /reklama.
*
* Trzymamy je osobno od leada, bo to dwie rozne rzeczy: lead to KONTAKT (jeden na osobe, zasila
* kampanie), a zgloszenie to ZDARZENIE - ta sama firma moze napisac kilka razy o rozne kategorie i
* kazda wiadomosc ma zostac czytelna w panelu. Upychanie tresci w notatce leada gubiloby daty,
* kolejnosc i mozliwosc oznaczenia pojedynczej wiadomosci jako obsluzonej.
*/
@Entity
@Table(name = "advertising_enquiries")
public class AdvertisingEnquiry {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String fullName;
@Column(nullable = false, length = 160)
private String companyName;
@Column(nullable = false, length = 160)
private String email;
@Column(nullable = false, length = 40)
private String phone;
@Column(nullable = false, length = 80)
private String serviceCategory;
@Column(length = 2000)
private String message;
@Column(nullable = false)
private boolean handled = false;
private Instant handledAt;
@Column(length = 180)
private String handledByEmail;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
createdAt = Instant.now();
}
public Long getId() {
return id;
}
public String getFullName() {
return fullName;
}
public void setFullName(String fullName) {
this.fullName = fullName;
}
public String getCompanyName() {
return companyName;
}
public void setCompanyName(String companyName) {
this.companyName = companyName;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPhone() {
return phone;
}
public void setPhone(String phone) {
this.phone = phone;
}
public String getServiceCategory() {
return serviceCategory;
}
public void setServiceCategory(String serviceCategory) {
this.serviceCategory = serviceCategory;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public boolean isHandled() {
return handled;
}
public void setHandled(boolean handled) {
this.handled = handled;
}
public Instant getHandledAt() {
return handledAt;
}
public void setHandledAt(Instant handledAt) {
this.handledAt = handledAt;
}
public String getHandledByEmail() {
return handledByEmail;
}
public void setHandledByEmail(String handledByEmail) {
this.handledByEmail = handledByEmail;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,33 @@
package pl.polskalokalnie.lead;
import jakarta.validation.Valid;
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;
/**
* Publiczny formularz zainteresowania reklama (strona /reklama).
*
* Endpoint nie zwraca tresci (204, jak pozostale akcje bez wyniku w tym API) - front pokazuje
* wlasne potwierdzenie. Nie odpowiadamy tez, czy dany
* kontakt byl juz w bazie, zeby formularz nie stal sie sposobem na sprawdzanie, kto sie zglaszal.
*/
@RestController
@RequestMapping("/api/advertising-enquiries")
public class AdvertisingEnquiryController {
private final AdvertisingEnquiryService advertisingEnquiryService;
public AdvertisingEnquiryController(AdvertisingEnquiryService advertisingEnquiryService) {
this.advertisingEnquiryService = advertisingEnquiryService;
}
@PostMapping
@ResponseStatus(HttpStatus.NO_CONTENT)
public void submit(@Valid @RequestBody AdvertisingEnquiryRequest request) {
advertisingEnquiryService.register(request);
}
}
@@ -0,0 +1,11 @@
package pl.polskalokalnie.lead;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AdvertisingEnquiryRepository extends JpaRepository<AdvertisingEnquiry, Long> {
List<AdvertisingEnquiry> findAllByOrderByCreatedAtDesc();
long countByHandledFalse();
}
@@ -0,0 +1,25 @@
package pl.polskalokalnie.lead;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Zgloszenie z formularza "Zainteresowany reklama?" na stronie /reklama.
*
* Formularz jest publiczny - pokazujemy go, gdy uzytkownik wejdzie w kategorie uslug, ktorej
* jeszcze nie obslugujemy. Zgloszenie laduje w bazie leadow ze zrodlem FORM, wiec obsluguje je
* ten sam panel marketingowy co pozostale kontakty.
*/
public record AdvertisingEnquiryRequest(
@NotBlank(message = "Podaj imię i nazwisko") @Size(max = 120) String fullName,
@NotBlank(message = "Podaj nazwę firmy") @Size(max = 160) String companyName,
@NotBlank(message = "Podaj adres e-mail") @Email(message = "Podaj poprawny adres e-mail") @Size(max = 160) String email,
@NotBlank(message = "Podaj numer telefonu") @Size(max = 40) String phone,
@NotBlank(message = "Wybierz rodzaj usługi") @Size(max = 80) String serviceCategory,
@Size(max = 2000) String message,
// Bez zgody nie mamy podstawy, zeby oddzwonic z oferta - dlatego to pole wymagane, a nie opcjonalne.
@AssertTrue(message = "Potrzebujemy zgody na kontakt") Boolean contactConsent
) {
}
@@ -0,0 +1,34 @@
package pl.polskalokalnie.lead;
import java.time.Instant;
/** Zgloszenie reklamowe w postaci pokazywanej w panelu administratora. */
public record AdvertisingEnquiryResponse(
Long id,
String fullName,
String companyName,
String email,
String phone,
String serviceCategory,
String message,
boolean handled,
Instant handledAt,
String handledByEmail,
Instant createdAt
) {
public static AdvertisingEnquiryResponse from(AdvertisingEnquiry enquiry) {
return new AdvertisingEnquiryResponse(
enquiry.getId(),
enquiry.getFullName(),
enquiry.getCompanyName(),
enquiry.getEmail(),
enquiry.getPhone(),
enquiry.getServiceCategory(),
enquiry.getMessage(),
enquiry.isHandled(),
enquiry.getHandledAt(),
enquiry.getHandledByEmail(),
enquiry.getCreatedAt()
);
}
}
@@ -0,0 +1,65 @@
package pl.polskalokalnie.lead;
import java.time.Instant;
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;
/**
* Zgloszenia z publicznego formularza reklamowego (/reklama).
*
* Jedno zgloszenie trafia w dwa miejsca i kazde ma inne zadanie:
* - `advertising_enquiries` to lista wiadomosci dla administratora (osobna zakladka w panelu),
* - lead to kontakt w bazie marketingowej, ktory zasila kampanie.
* Zapis leada jest celowo "miekki" - blad synchronizacji nie moze skasowac wiadomosci, ktora
* uzytkownik wlasnie wyslal.
*/
@Service
public class AdvertisingEnquiryService {
private final AdvertisingEnquiryRepository repository;
private final LeadService leadService;
public AdvertisingEnquiryService(AdvertisingEnquiryRepository repository, LeadService leadService) {
this.repository = repository;
this.leadService = leadService;
}
@Transactional
public void register(AdvertisingEnquiryRequest request) {
AdvertisingEnquiry enquiry = new AdvertisingEnquiry();
enquiry.setFullName(request.fullName().trim());
enquiry.setCompanyName(request.companyName().trim());
enquiry.setEmail(request.email().trim());
enquiry.setPhone(request.phone().trim());
enquiry.setServiceCategory(request.serviceCategory().trim());
enquiry.setMessage(request.message() == null || request.message().isBlank() ? null : request.message().trim());
repository.save(enquiry);
leadService.registerAdvertisingEnquiry(request);
}
public List<AdvertisingEnquiryResponse> findAllForAdmin() {
return repository.findAllByOrderByCreatedAtDesc().stream().map(AdvertisingEnquiryResponse::from).toList();
}
/** Oznaczenie zgloszenia jako obsluzonego (lub cofniecie tego oznaczenia). */
@Transactional
public AdvertisingEnquiryResponse setHandled(Long id, boolean handled, String adminEmail) {
AdvertisingEnquiry enquiry = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zgłoszenie nie istnieje"));
enquiry.setHandled(handled);
enquiry.setHandledAt(handled ? Instant.now() : null);
enquiry.setHandledByEmail(handled ? adminEmail : null);
return AdvertisingEnquiryResponse.from(repository.save(enquiry));
}
@Transactional
public void delete(Long id) {
AdvertisingEnquiry enquiry = repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Zgłoszenie nie istnieje"));
repository.delete(enquiry);
}
}
@@ -6,6 +6,7 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.Optional;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -19,6 +20,12 @@ import org.springframework.web.server.ResponseStatusException;
@Service
public class LeadService {
/** Tag, po ktorym w panelu marketingowym poznajemy zgloszenia z formularza reklamowego. */
private static final String ADVERTISING_TAG = "reklama";
/** Limit kolumny `notes` w bazie - dluzszy tekst nie zapisze sie i wywolalby blad 500. */
private static final int NOTES_MAX_LENGTH = 2000;
private final LeadRepository leadRepository;
public LeadService(LeadRepository leadRepository) {
@@ -191,6 +198,87 @@ public class LeadService {
return value != null && value.toLowerCase(Locale.ROOT).contains(needle);
}
/**
* Zgloszenie z publicznego formularza reklamowego (/reklama).
*
* W odroznieniu od recznego dodawania leada powtorzony kontakt NIE jest bledem - ktos moze
* zapytac o kilka kategorii uslug. Dopisujemy wtedy nowe zgloszenie do notatki i dokladamy
* tag kategorii, zamiast odrzucac formularz komunikatem, ze taki lead juz istnieje.
*/
@Transactional
public void registerAdvertisingEnquiry(AdvertisingEnquiryRequest request) {
String email = normalizeEmail(request.email());
String phone = normalizePhone(request.phone());
boolean consent = Boolean.TRUE.equals(request.contactConsent());
Lead lead = (email == null ? Optional.<Lead>empty() : leadRepository.findFirstByEmailIgnoreCase(email))
.or(() -> phone == null ? Optional.<Lead>empty() : leadRepository.findFirstByPhone(phone))
.orElseGet(Lead::new);
if (lead.getId() == null) {
lead.setSource(LeadSource.FORM);
lead.setEmail(email);
lead.setPhone(phone);
} else {
// Trafilismy w istniejacy kontakt - uzupelniamy tylko brakujace pola, zeby zgloszenie
// nie nadpisalo danych zebranych wczesniej. Rozbieznosci ladują w notatce (nizej),
// wiec podany w formularzu adres czy numer nigdy nie przepada.
if (lead.getEmail() == null) {
lead.setEmail(email);
}
if (lead.getPhone() == null) {
lead.setPhone(phone);
}
}
lead.setName(trimToNull(request.fullName()));
List<String> tags = new ArrayList<>(lead.getTags());
tags.add(ADVERTISING_TAG);
tags.add(trimToNull(request.serviceCategory()));
lead.setTags(cleanTags(tags));
// Zgoda z formularza dotyczy kontaktu z oferta - obejmuje oba kanaly, bo kontakt moze byc
// telefoniczny. Raz cofnietej zgody nie przywracamy tutaj po cichu.
lead.setEmailConsent(consent);
lead.setSmsConsent(consent);
lead.setNotes(appendEnquiryNote(lead.getNotes(), request, lead, email, phone));
leadRepository.save(lead);
}
/**
* Dokleja nowe zgloszenie na koncu notatki leada.
*
* Kolumna `notes` ma 2000 znakow, a ten sam kontakt moze zglosic sie wielokrotnie - dlatego przy
* przekroczeniu limitu obcinamy najstarszy fragment. Publiczny formularz nie moze wywrocic sie
* na bledzie bazy tylko dlatego, ze ktos pisze po raz dziesiaty.
*/
private static String appendEnquiryNote(String existingNotes, AdvertisingEnquiryRequest request,
Lead lead, String email, String phone) {
StringBuilder note = new StringBuilder("Zgłoszenie reklamowe");
note.append("\nFirma: ").append(trimToNull(request.companyName()));
note.append("\nRodzaj usługi: ").append(trimToNull(request.serviceCategory()));
// Kontakt zapisujemy tylko wtedy, gdy rozni sie od tego na leadzie - inaczej dublowalby
// kolumny, a przy rozbieznosci to jedyne miejsce, w ktorym dane z formularza przetrwaja.
if (email != null && !email.equalsIgnoreCase(lead.getEmail())) {
note.append("\nE-mail z formularza: ").append(email);
}
if (phone != null && !phone.equals(lead.getPhone())) {
note.append("\nTelefon z formularza: ").append(phone);
}
String message = trimToNull(request.message());
if (message != null) {
note.append("\nWiadomość: ").append(message);
}
String previous = trimToNull(existingNotes);
String combined = previous == null ? note.toString() : previous + "\n\n" + note;
if (combined.length() <= NOTES_MAX_LENGTH) {
return combined;
}
return combined.substring(combined.length() - NOTES_MAX_LENGTH);
}
private static List<String> cleanTags(List<String> tags) {
List<String> cleaned = new ArrayList<>();
if (tags != null) {
@@ -26,13 +26,18 @@ public class ListingController {
this.listingService = listingService;
}
/**
* @param directOnly zostaw wylacznie oferty bezposrednie, czyli wystawione z kont osob
* prywatnych (ustawienie "Pokaz tylko oferty bezposrednie" w /konto/ustawienia)
*/
@GetMapping
public List<ListingResponse> search(
@RequestParam(required = false) String city,
@RequestParam(required = false) OfferType offerType,
@RequestParam(required = false) PropertyType propertyType
@RequestParam(required = false) PropertyType propertyType,
@RequestParam(defaultValue = "false") boolean directOnly
) {
return listingService.search(city, offerType, propertyType);
return listingService.search(city, offerType, propertyType, directOnly);
}
@GetMapping("/mine")
@@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.AccountType;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
import pl.polskalokalnie.user.UserSettingsService;
@@ -86,17 +87,30 @@ public class ListingService {
.thenComparing(PropertyListing::getCreatedAt, Comparator.reverseOrder());
}
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType) {
public List<ListingResponse> search(String city, OfferType offerType, PropertyType propertyType, boolean directOnly) {
return listingRepository.findAll().stream()
.filter(listing -> listing.getStatus() == ListingStatus.APPROVED)
.filter(listing -> city == null || normalizeCity(listing.getCity()).equals(normalizeCity(city)))
.filter(listing -> offerType == null || listing.getOfferType() == offerType)
.filter(listing -> propertyType == null || listing.getPropertyType() == propertyType)
.filter(listing -> !directOnly || isDirectOffer(listing))
.sorted(promotedFirst())
.map(listing -> ListingResponse.from(listing, listing.isApproximateLocation()))
.toList();
}
/**
* Oferta bezposrednia to taka, ktora wystawilo konto osoby prywatnej.
*
* Ogloszenia bez konta w serwisie (np. wpisy demonstracyjne z samym adresem e-mail) NIE sa
* uznawane za bezposrednie - skoro nie da sie potwierdzic, ze stoi za nimi osoba prywatna, to
* pokazanie ich pod filtrem obiecujacym "tylko od osob prywatnych" wprowadzaloby w blad.
*/
private boolean isDirectOffer(PropertyListing listing) {
AppUser owner = ownerOf(listing);
return owner != null && owner.getAccountType() == AccountType.PERSONAL;
}
/**
* Szczegoly ogloszenia. Publicznie dostepne sa wylacznie ogloszenia opublikowane - wstrzymane,
* oczekujace i odrzucone widzi tylko wlasciciel i administracja, takze przy wejsciu z linku.