poradniki

This commit is contained in:
2026-08-20 16:24:47 +02:00
parent f9f030ba14
commit 6c35589e70
17 changed files with 4570 additions and 89 deletions
@@ -0,0 +1,90 @@
package pl.polskalokalnie.article;
import java.util.List;
import java.util.Map;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/**
* Panel administratora: moderacja propozycji artykułów użytkowników ("Poradniki - prośby").
* Dostęp tylko dla roli ADMIN (reguła /api/admin/** w SecurityConfig).
*/
@RestController
@RequestMapping("/api/admin/articles")
public class AdminArticleController {
private final ArticleService service;
public AdminArticleController(ArticleService service) {
this.service = service;
}
@GetMapping
public List<AdminArticleResponse> list(@RequestParam(value = "status", required = false) String status) {
return service.listForAdmin(parseStatus(status));
}
@GetMapping("/pending-count")
public Map<String, Long> pendingCount() {
return Map.of("count", service.pendingCount());
}
@GetMapping("/{id}")
public AdminArticleResponse get(@PathVariable Long id) {
return service.getForAdmin(id);
}
@PostMapping("/{id}/approve")
public AdminArticleResponse approve(@PathVariable Long id,
@RequestBody(required = false) ApproveRequest request,
Authentication authentication) {
return service.approve(id, authentication.getName(), parseChannel(request != null ? request.channel() : null));
}
@PostMapping("/{id}/reject")
public AdminArticleResponse reject(@PathVariable Long id,
@RequestBody(required = false) RejectRequest request,
Authentication authentication) {
return service.reject(id, request != null ? request.reason() : null, authentication.getName(),
parseChannel(request != null ? request.channel() : null));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
service.delete(id);
}
private ArticleStatus parseStatus(String status) {
if (status == null || status.isBlank() || "ALL".equalsIgnoreCase(status)) {
return null;
}
try {
return ArticleStatus.valueOf(status.toUpperCase());
} catch (IllegalArgumentException ex) {
return null;
}
}
private ArticleService.NotifyChannel parseChannel(String channel) {
if (channel != null && "EMAIL".equalsIgnoreCase(channel)) {
return ArticleService.NotifyChannel.EMAIL;
}
return ArticleService.NotifyChannel.IN_APP;
}
public record ApproveRequest(String channel) {
}
public record RejectRequest(String reason, String channel) {
}
}
@@ -0,0 +1,48 @@
package pl.polskalokalnie.article;
import java.time.Instant;
import java.util.List;
/** Pełny widok propozycji artykułu dla panelu administratora (z treścią, załącznikami i statusem). */
public record AdminArticleResponse(
Long id,
String topicSlug,
String title,
String excerpt,
String content,
List<String> attachments,
String authorName,
String authorNick,
String authorEmail,
String contactName,
String contactEmail,
String contactPhone,
int readMinutes,
String status,
String rejectionReason,
String reviewedByEmail,
Instant reviewedAt,
Instant createdAt
) {
public static AdminArticleResponse from(Article article) {
return new AdminArticleResponse(
article.getId(),
article.getTopicSlug(),
article.getTitle(),
article.getExcerpt(),
article.getContent(),
List.copyOf(article.getAttachments()),
article.getAuthorName(),
article.getAuthorNick(),
article.getAuthorEmail(),
article.getContactName(),
article.getContactEmail(),
article.getContactPhone(),
article.getReadMinutes(),
article.getStatus().name(),
article.getRejectionReason(),
article.getReviewedByEmail(),
article.getReviewedAt(),
article.getCreatedAt());
}
}
@@ -0,0 +1,246 @@
package pl.polskalokalnie.article;
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.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* Poradnik/artykuł dodany przez użytkownika w danym temacie (np. "kredyty-hipoteczne").
* Treść to oczyszczony HTML z edytora - renderowana na froncie przez DOMPurify.
*/
@Entity
@Table(name = "articles")
public class Article {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Slug tematu = ostatni segment adresu poradnika (kupno-mieszkania, kredyty-hipoteczne, ...).
@Column(nullable = false, length = 60)
private String topicSlug;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, length = 400)
private String excerpt;
@Column(nullable = false, columnDefinition = "text")
private String content;
// Autor - konto, z którego dodano artykuł. Nazwę i nick trzymamy zdenormalizowane dla listy.
@Column(nullable = false, length = 180)
private String authorEmail;
@Column(nullable = false, length = 160)
private String authorName;
@Column(length = 80)
private String authorNick;
// Dane kontaktowe podane w formularzu - po nich zespół kontaktuje się w sprawie publikacji.
// Mogą różnić się od danych konta (autor może podać inny adres do kontaktu).
@Column(length = 160)
private String contactName;
@Column(length = 180)
private String contactEmail;
@Column(length = 40)
private String contactPhone;
// Szacowany czas czytania w minutach, liczony z długości treści przy zapisie.
@Column(nullable = false)
private int readMinutes;
@Column(nullable = false)
private long views = 0;
// Opcjonalne załączniki (zdjęcia jako data URL). Osobna tabela z kolumną TEXT.
@ElementCollection
@CollectionTable(name = "article_attachments", joinColumns = @JoinColumn(name = "article_id"))
@OrderColumn(name = "position")
@Column(name = "attachment", columnDefinition = "text")
private List<String> attachments = new ArrayList<>();
// Stan moderacji. Nowe propozycje trafiają do zespołu jako PENDING.
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private ArticleStatus status = ArticleStatus.PENDING;
@Column(length = 180)
private String reviewedByEmail;
private Instant reviewedAt;
@Column(length = 500)
private String rejectionReason;
@Column(nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void onCreate() {
if (createdAt == null) {
createdAt = Instant.now();
}
}
public Long getId() {
return id;
}
public String getTopicSlug() {
return topicSlug;
}
public void setTopicSlug(String topicSlug) {
this.topicSlug = topicSlug;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getExcerpt() {
return excerpt;
}
public void setExcerpt(String excerpt) {
this.excerpt = excerpt;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getAuthorEmail() {
return authorEmail;
}
public void setAuthorEmail(String authorEmail) {
this.authorEmail = authorEmail;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public String getAuthorNick() {
return authorNick;
}
public void setAuthorNick(String authorNick) {
this.authorNick = authorNick;
}
public String getContactName() {
return contactName;
}
public void setContactName(String contactName) {
this.contactName = contactName;
}
public String getContactEmail() {
return contactEmail;
}
public void setContactEmail(String contactEmail) {
this.contactEmail = contactEmail;
}
public String getContactPhone() {
return contactPhone;
}
public void setContactPhone(String contactPhone) {
this.contactPhone = contactPhone;
}
public int getReadMinutes() {
return readMinutes;
}
public void setReadMinutes(int readMinutes) {
this.readMinutes = readMinutes;
}
public long getViews() {
return views;
}
public void setViews(long views) {
this.views = views;
}
public List<String> getAttachments() {
return attachments;
}
public void setAttachments(List<String> attachments) {
this.attachments = attachments;
}
public ArticleStatus getStatus() {
return status;
}
public void setStatus(ArticleStatus status) {
this.status = status;
}
public String getReviewedByEmail() {
return reviewedByEmail;
}
public void setReviewedByEmail(String reviewedByEmail) {
this.reviewedByEmail = reviewedByEmail;
}
public Instant getReviewedAt() {
return reviewedAt;
}
public void setReviewedAt(Instant reviewedAt) {
this.reviewedAt = reviewedAt;
}
public String getRejectionReason() {
return rejectionReason;
}
public void setRejectionReason(String rejectionReason) {
this.rejectionReason = rejectionReason;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,29 @@
package pl.polskalokalnie.article;
import java.time.Instant;
/** Skrócona wersja artykułu na listę w danym temacie (bez pełnej treści). */
public record ArticleCardResponse(
Long id,
String topicSlug,
String title,
String excerpt,
String authorName,
String authorNick,
int readMinutes,
long views,
Instant createdAt
) {
public static ArticleCardResponse from(Article article) {
return new ArticleCardResponse(
article.getId(),
article.getTopicSlug(),
article.getTitle(),
article.getExcerpt(),
article.getAuthorName(),
article.getAuthorNick(),
article.getReadMinutes(),
article.getViews(),
article.getCreatedAt());
}
}
@@ -0,0 +1,48 @@
package pl.polskalokalnie.article;
import jakarta.validation.Valid;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
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.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
/** Poradniki dodawane przez użytkowników. Odczyt publiczny, dodawanie tylko dla zalogowanych. */
@RestController
@RequestMapping("/api/articles")
public class ArticleController {
private final ArticleService service;
public ArticleController(ArticleService service) {
this.service = service;
}
@GetMapping
public List<ArticleCardResponse> listByTopic(@RequestParam("topic") String topic) {
return service.listByTopic(topic);
}
@GetMapping("/counts")
public Map<String, Long> counts() {
return service.countsByTopic();
}
@GetMapping("/{id}")
public ArticleDetailResponse get(@PathVariable Long id) {
return service.get(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ArticleDetailResponse create(@Valid @RequestBody ArticleCreateRequest request, Authentication authentication) {
return service.create(request, authentication.getName());
}
}
@@ -0,0 +1,20 @@
package pl.polskalokalnie.article;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.util.List;
/** Dane formularza "Zaproponuj artykuł". Treść to HTML z edytora, załączniki to data URL zdjęć. */
public record ArticleCreateRequest(
@NotBlank @Size(max = 60) String topicSlug,
@NotBlank @Size(min = 5, max = 200) String title,
@NotBlank @Size(min = 10, max = 400) String excerpt,
@NotBlank @Size(min = 30, max = 40000) String content,
// Dane kontaktowe autora - po nich zespół skontaktuje się w sprawie publikacji.
@NotBlank @Size(min = 3, max = 160) String contactName,
@NotBlank @Email @Size(max = 180) String contactEmail,
@Size(max = 40) String contactPhone,
List<String> attachments
) {
}
@@ -0,0 +1,37 @@
package pl.polskalokalnie.article;
import java.time.Instant;
import java.util.List;
/** Pełny artykuł do widoku szczegółowego. */
public record ArticleDetailResponse(
Long id,
String topicSlug,
String title,
String excerpt,
String content,
List<String> attachments,
String authorName,
String authorNick,
int readMinutes,
long views,
String status,
Instant createdAt
) {
public static ArticleDetailResponse from(Article article) {
return new ArticleDetailResponse(
article.getId(),
article.getTopicSlug(),
article.getTitle(),
article.getExcerpt(),
article.getContent(),
List.copyOf(article.getAttachments()),
article.getAuthorName(),
article.getAuthorNick(),
article.getReadMinutes(),
article.getViews(),
article.getStatus().name(),
article.getCreatedAt());
}
}
@@ -0,0 +1,25 @@
package pl.polskalokalnie.article;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface ArticleRepository extends JpaRepository<Article, Long> {
// Widok publiczny - tylko opublikowane.
List<Article> findByTopicSlugAndStatusOrderByCreatedAtDesc(String topicSlug, ArticleStatus status);
Optional<Article> findByIdAndStatus(Long id, ArticleStatus status);
// Panel admina.
List<Article> findByStatusOrderByCreatedAtDesc(ArticleStatus status);
List<Article> findAllByOrderByCreatedAtDesc();
long countByStatus(ArticleStatus status);
// Liczba opublikowanych artykułów w rozbiciu na tematy - do liczników na kafelkach /poradniki.
@Query("select a.topicSlug, count(a) from Article a where a.status = :status group by a.topicSlug")
List<Object[]> countGroupedByTopic(ArticleStatus status);
}
@@ -0,0 +1,247 @@
package pl.polskalokalnie.article;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.auth.AccountEmailService;
import pl.polskalokalnie.moderation.TextModerationService;
import pl.polskalokalnie.notification.NotificationService;
import pl.polskalokalnie.user.AppUser;
import pl.polskalokalnie.user.UserRepository;
@Service
public class ArticleService {
/** Kanał, którym powiadamiamy autora o decyzji. */
public enum NotifyChannel { IN_APP, EMAIL }
// Tematy zgodne z ostatnim segmentem adresów poradników na froncie (ROUTES.guide*).
private static final Set<String> ALLOWED_TOPICS = Set.of(
"kupno-mieszkania",
"kredyty-hipoteczne",
"wynajem",
"inwestowanie",
"remont-i-wykonczenie",
"prawo-i-podatki");
private static final int MAX_ATTACHMENTS = 6;
private final ArticleRepository repository;
private final UserRepository userRepository;
private final TextModerationService textModerationService;
private final NotificationService notificationService;
private final AccountEmailService accountEmailService;
public ArticleService(ArticleRepository repository,
UserRepository userRepository,
TextModerationService textModerationService,
NotificationService notificationService,
AccountEmailService accountEmailService) {
this.repository = repository;
this.userRepository = userRepository;
this.textModerationService = textModerationService;
this.notificationService = notificationService;
this.accountEmailService = accountEmailService;
}
// ---- Widok publiczny (tylko opublikowane) ----
public List<ArticleCardResponse> listByTopic(String topicSlug) {
requireAllowedTopic(topicSlug);
return repository.findByTopicSlugAndStatusOrderByCreatedAtDesc(topicSlug, ArticleStatus.PUBLISHED).stream()
.map(ArticleCardResponse::from)
.toList();
}
/** Liczba opublikowanych artykułów w każdym temacie (0 dla tematów bez wpisów). */
public Map<String, Long> countsByTopic() {
Map<String, Long> counts = new LinkedHashMap<>();
for (String topic : ALLOWED_TOPICS) {
counts.put(topic, 0L);
}
for (Object[] row : repository.countGroupedByTopic(ArticleStatus.PUBLISHED)) {
String slug = (String) row[0];
if (counts.containsKey(slug)) {
counts.put(slug, (Long) row[1]);
}
}
return counts;
}
@Transactional
public ArticleDetailResponse get(Long id) {
Article article = repository.findByIdAndStatus(id, ArticleStatus.PUBLISHED)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Artykuł nie istnieje"));
article.setViews(article.getViews() + 1);
return ArticleDetailResponse.from(article);
}
@Transactional
public ArticleDetailResponse create(ArticleCreateRequest request, String authorEmail) {
String topicSlug = request.topicSlug().trim();
requireAllowedTopic(topicSlug);
String plainContent = stripHtml(request.content());
if (plainContent.length() < 30) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Treść artykułu jest zbyt krótka");
}
// Filtr wulgaryzmów na widocznym tekście (tytuł, zajawka, treść bez znaczników, imię kontaktu).
textModerationService.validateOrThrow(request.title(), request.excerpt(), plainContent, request.contactName());
AppUser author = userRepository.findByEmailIgnoreCase(authorEmail)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Nie znaleziono konta autora"));
Article article = new Article();
article.setTopicSlug(topicSlug);
article.setTitle(request.title().trim());
article.setExcerpt(request.excerpt().trim());
article.setContent(request.content());
article.setAttachments(sanitizeAttachments(request.attachments()));
article.setAuthorEmail(author.getEmail());
article.setAuthorName(author.getFullName() != null ? author.getFullName() : author.getEmail());
article.setAuthorNick(author.getNick());
article.setContactName(request.contactName().trim());
article.setContactEmail(request.contactEmail().trim());
article.setContactPhone(request.contactPhone() != null && !request.contactPhone().isBlank()
? request.contactPhone().trim() : null);
article.setReadMinutes(estimateReadMinutes(plainContent));
// Nowa propozycja czeka na akceptację zespołu.
article.setStatus(ArticleStatus.PENDING);
return ArticleDetailResponse.from(repository.save(article));
}
// ---- Panel administratora ----
@Transactional(readOnly = true)
public List<AdminArticleResponse> listForAdmin(ArticleStatus status) {
List<Article> articles = status != null
? repository.findByStatusOrderByCreatedAtDesc(status)
: repository.findAllByOrderByCreatedAtDesc();
return articles.stream().map(AdminArticleResponse::from).toList();
}
@Transactional(readOnly = true)
public AdminArticleResponse getForAdmin(Long id) {
return AdminArticleResponse.from(findOrThrow(id));
}
/** Liczba propozycji oczekujących na moderację (badge w panelu). */
public long pendingCount() {
return repository.countByStatus(ArticleStatus.PENDING);
}
@Transactional
public AdminArticleResponse approve(Long id, String adminEmail, NotifyChannel channel) {
Article article = findOrThrow(id);
article.setStatus(ArticleStatus.PUBLISHED);
article.setRejectionReason(null);
article.setReviewedByEmail(adminEmail);
article.setReviewedAt(Instant.now());
Article saved = repository.save(article);
notifyAuthor(saved, true, null, channel);
return AdminArticleResponse.from(saved);
}
@Transactional
public AdminArticleResponse reject(Long id, String reason, String adminEmail, NotifyChannel channel) {
Article article = findOrThrow(id);
String cleanReason = reason != null && !reason.isBlank() ? reason.trim() : null;
article.setStatus(ArticleStatus.REJECTED);
article.setRejectionReason(cleanReason);
article.setReviewedByEmail(adminEmail);
article.setReviewedAt(Instant.now());
Article saved = repository.save(article);
notifyAuthor(saved, false, cleanReason, channel);
return AdminArticleResponse.from(saved);
}
// Powiadamia autora o decyzji wybranym przez admina kanałem. Błąd powiadomienia nie przerywa
// samej moderacji - status i tak został zapisany.
private void notifyAuthor(Article article, boolean approved, String reason, NotifyChannel channel) {
NotifyChannel effective = channel != null ? channel : NotifyChannel.IN_APP;
String path = approved
? "/poradniki/artykul/" + article.getId()
: "/poradniki/dodaj?temat=" + article.getTopicSlug();
try {
if (effective == NotifyChannel.EMAIL) {
// E-mail idzie na adres kontaktowy podany w formularzu (fallback: adres konta).
String toEmail = article.getContactEmail() != null && !article.getContactEmail().isBlank()
? article.getContactEmail() : article.getAuthorEmail();
String toName = article.getContactName() != null && !article.getContactName().isBlank()
? article.getContactName() : article.getAuthorName();
accountEmailService.sendArticleDecision(
toEmail, toName, article.getTitle(), approved, reason, path);
} else {
String title = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty";
String body = approved
? "„" + article.getTitle() + "” jest już widoczny w poradnikach. Dziękujemy!"
: (reason != null
? "„" + article.getTitle() + "” nie został przyjęty. Powód: " + reason
: "„" + article.getTitle() + "” nie został przyjęty. Możesz poprawić treść i wysłać ponownie.");
notificationService.create(article.getAuthorEmail(), "system", approved ? "check" : "warning",
title, body, path, "article-decision-" + article.getId() + "-" + article.getStatus().name());
}
} catch (Exception ex) {
// Świadomie pomijamy - decyzja moderacyjna jest ważniejsza niż dostarczenie powiadomienia.
}
}
@Transactional
public void delete(Long id) {
if (!repository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Artykuł nie istnieje");
}
repository.deleteById(id);
}
// ---- Pomocnicze ----
private Article findOrThrow(Long id) {
return repository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Artykuł nie istnieje"));
}
private void requireAllowedTopic(String topicSlug) {
if (topicSlug == null || !ALLOWED_TOPICS.contains(topicSlug)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Nieznany temat poradnika");
}
}
// Przyjmujemy tylko obrazy w formie data URL, maksymalnie kilka sztuk.
private List<String> sanitizeAttachments(List<String> attachments) {
List<String> cleaned = new ArrayList<>();
if (attachments == null) {
return cleaned;
}
for (String attachment : attachments) {
if (attachment != null && attachment.startsWith("data:image/")) {
cleaned.add(attachment);
if (cleaned.size() >= MAX_ATTACHMENTS) {
break;
}
}
}
return cleaned;
}
// Prosty czas czytania: ~200 słów na minutę, minimum 1 minuta.
private int estimateReadMinutes(String plainText) {
int words = plainText.isBlank() ? 0 : plainText.trim().split("\\s+").length;
return Math.max(1, (int) Math.ceil(words / 200.0));
}
private String stripHtml(String html) {
if (html == null) {
return "";
}
return html.replaceAll("<[^>]*>", " ").replaceAll("&nbsp;", " ").replaceAll("\\s+", " ").trim();
}
}
@@ -0,0 +1,8 @@
package pl.polskalokalnie.article;
/** Stan propozycji artykułu w procesie moderacji. */
public enum ArticleStatus {
PENDING,
PUBLISHED,
REJECTED
}
@@ -61,6 +61,35 @@ public class AccountEmailService {
send(email, "Reset hasła w serwisie Polska Lokalnie", html, "RESET HASŁA", link);
}
/**
* Powiadomienie autora o decyzji zespołu w sprawie zaproponowanego artykułu.
* approved = true → artykuł opublikowany, link prowadzi do artykułu; false → odrzucony,
* link prowadzi do formularza, aby poprawić i wysłać ponownie.
*/
public void sendArticleDecision(String email, String name, String articleTitle,
boolean approved, String reason, String path) {
String link = baseUrl + (path == null ? "" : path);
String heading = approved ? "Twój artykuł został opublikowany" : "Twój artykuł nie został przyjęty";
String safeTitle = articleTitle == null ? "" : articleTitle;
String body;
if (approved) {
body = "<p>Dobra wiadomość! Twoja propozycja artykułu <strong>„" + safeTitle + "”</strong> została"
+ " zaakceptowana przez nasz zespół i jest już widoczna w poradnikach.</p>"
+ "<p>Dziękujemy, że dzielisz się swoją wiedzą z innymi.</p>";
} else {
String reasonHtml = reason == null || reason.isBlank() ? ""
: "<p style=\"background:#fbe9ec;border-radius:8px;padding:12px 14px;color:#a23144;\"><strong>Powód:</strong> "
+ reason.trim() + "</p>";
body = "<p>Dziękujemy za propozycję artykułu <strong>„" + safeTitle + "”</strong>. Po analizie nasz zespół"
+ " zdecydował, że w obecnej formie nie zostanie on opublikowany.</p>"
+ reasonHtml
+ "<p>Zachęcamy do poprawienia treści i wysłania jej ponownie.</p>";
}
String ctaLabel = approved ? "Zobacz artykuł" : "Popraw i wyślij ponownie";
send(email, "Polska Lokalnie - " + heading, template(heading, greeting(name), body, ctaLabel, link),
approved ? "ARTYKUŁ ZAAKCEPTOWANY" : "ARTYKUŁ ODRZUCONY", link);
}
private void send(String email, String subject, String html, String label, String link) {
SmtpConfig smtp = mailConfigService.getSmtp();
boolean configured = smtp.isEnabled()
@@ -47,6 +47,8 @@ public class SecurityConfig {
.requestMatchers("/api/auth/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings/mine").authenticated()
.requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
.requestMatchers(HttpMethod.POST, "/api/banners/*/click").permitAll()
.requestMatchers(HttpMethod.POST, "/api/payments/autopay/itn").permitAll()