diff --git a/backend/src/main/java/pl/polskalokalnie/article/AdminArticleController.java b/backend/src/main/java/pl/polskalokalnie/article/AdminArticleController.java new file mode 100644 index 0000000..cd8ce9a --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/AdminArticleController.java @@ -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 list(@RequestParam(value = "status", required = false) String status) { + return service.listForAdmin(parseStatus(status)); + } + + @GetMapping("/pending-count") + public Map 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) { + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/article/AdminArticleResponse.java b/backend/src/main/java/pl/polskalokalnie/article/AdminArticleResponse.java new file mode 100644 index 0000000..4a13b18 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/AdminArticleResponse.java @@ -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 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()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/article/Article.java b/backend/src/main/java/pl/polskalokalnie/article/Article.java new file mode 100644 index 0000000..89ae9ba --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/Article.java @@ -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 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 getAttachments() { + return attachments; + } + + public void setAttachments(List 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; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/article/ArticleCardResponse.java b/backend/src/main/java/pl/polskalokalnie/article/ArticleCardResponse.java new file mode 100644 index 0000000..20cfff5 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/ArticleCardResponse.java @@ -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()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/article/ArticleController.java b/backend/src/main/java/pl/polskalokalnie/article/ArticleController.java new file mode 100644 index 0000000..2933db1 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/ArticleController.java @@ -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 listByTopic(@RequestParam("topic") String topic) { + return service.listByTopic(topic); + } + + @GetMapping("/counts") + public Map 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()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/article/ArticleCreateRequest.java b/backend/src/main/java/pl/polskalokalnie/article/ArticleCreateRequest.java new file mode 100644 index 0000000..e3372ba --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/ArticleCreateRequest.java @@ -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 attachments +) { +} diff --git a/backend/src/main/java/pl/polskalokalnie/article/ArticleDetailResponse.java b/backend/src/main/java/pl/polskalokalnie/article/ArticleDetailResponse.java new file mode 100644 index 0000000..65834e7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/ArticleDetailResponse.java @@ -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 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()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/article/ArticleRepository.java b/backend/src/main/java/pl/polskalokalnie/article/ArticleRepository.java new file mode 100644 index 0000000..2068975 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/ArticleRepository.java @@ -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 { + + // Widok publiczny - tylko opublikowane. + List
findByTopicSlugAndStatusOrderByCreatedAtDesc(String topicSlug, ArticleStatus status); + + Optional
findByIdAndStatus(Long id, ArticleStatus status); + + // Panel admina. + List
findByStatusOrderByCreatedAtDesc(ArticleStatus status); + + List
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 countGroupedByTopic(ArticleStatus status); +} diff --git a/backend/src/main/java/pl/polskalokalnie/article/ArticleService.java b/backend/src/main/java/pl/polskalokalnie/article/ArticleService.java new file mode 100644 index 0000000..f170de5 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/ArticleService.java @@ -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 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 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 countsByTopic() { + Map 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 listForAdmin(ArticleStatus status) { + List
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 sanitizeAttachments(List attachments) { + List 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(" ", " ").replaceAll("\\s+", " ").trim(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/article/ArticleStatus.java b/backend/src/main/java/pl/polskalokalnie/article/ArticleStatus.java new file mode 100644 index 0000000..1507979 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/article/ArticleStatus.java @@ -0,0 +1,8 @@ +package pl.polskalokalnie.article; + +/** Stan propozycji artykułu w procesie moderacji. */ +public enum ArticleStatus { + PENDING, + PUBLISHED, + REJECTED +} diff --git a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java index 708d43d..35bde1a 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/AccountEmailService.java @@ -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 = "

Dobra wiadomość! Twoja propozycja artykułu „" + safeTitle + "” została" + + " zaakceptowana przez nasz zespół i jest już widoczna w poradnikach.

" + + "

Dziękujemy, że dzielisz się swoją wiedzą z innymi.

"; + } else { + String reasonHtml = reason == null || reason.isBlank() ? "" + : "

Powód: " + + reason.trim() + "

"; + body = "

Dziękujemy za propozycję artykułu „" + safeTitle + "”. Po analizie nasz zespół" + + " zdecydował, że w obecnej formie nie zostanie on opublikowany.

" + + reasonHtml + + "

Zachęcamy do poprawienia treści i wysłania jej ponownie.

"; + } + 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() diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index 4f7e3b7..ba39ec6 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -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() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 07a799f..eabe000 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,8 @@ -import { Fragment, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, type CSSProperties, type ChangeEvent as ReactChangeEvent, type ClipboardEvent as ReactClipboardEvent, type Dispatch, type DragEvent as ReactDragEvent, type FormEvent as ReactFormEvent, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type SetStateAction, type WheelEvent as ReactWheelEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import './styles.css'; import { Link, NavLink, Navigate, Outlet, Route, Routes, useLocation, useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom'; -import { ROUTES, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { ROUTES, GUIDE_TOPIC_ROUTES, GUIDE_TOPICS, guideTopicBySlug, guideTopicListPath, articlePath, guideAddPath, listingPath, publicProfilePath, publicProfileNickPath, listingEditPath, mapPath, negotiationPath, priceHistoryPath, companiesPath, advertisingPath, adminTabPath, ADMIN_PATH_TO_TAB, type AdminTabKey } from './routes'; +import { guideContents, type GuideContent, type GuideBlock } from './guides-data'; import { ProtectedRoute } from './ProtectedRoute'; import { Avatar, initialsFrom } from './Avatar'; import { apiFetch, useAuth } from './auth'; @@ -11,6 +12,7 @@ import { useNotifications, toDisplayNotifications } from './notifications'; import type { ServerNotification, DisplayNotification } from './notifications'; import { getManualTranslation } from './i18nOverrides'; import { AdminLeadsView, AdminCampaignsView, AdminSmtpConfigView, AdminSmsConfigView } from './admin/marketing'; +import { AdminArticleRequestsView } from './admin/articleRequests'; import { RichTextEditor, SafeHtml, stripHtml, isRichTextEmpty } from './RichText'; import { compressImageFile } from './media'; import { BannerSlot } from './Banners'; @@ -2187,44 +2189,43 @@ const addFooterBenefits = [ }, ]; -const guideTopics = [ - { - icon: 'house', - title: 'Kupno mieszkania', - text: 'Poradniki dla kupujących', - count: '23 artykuły', - }, - { - icon: 'percent', - title: 'Kredyty hipoteczne', - text: 'Finansowanie zakupu', - count: '18 artykułów', - }, - { - icon: 'key', - title: 'Wynajem', - text: 'Porady dla najemców i wynajmujących', - count: '16 artykułów', - }, - { - icon: 'chart', - title: 'Inwestowanie', - text: 'Analizy i strategie inwestycyjne', - count: '15 artykułów', - }, - { - icon: 'paint', - title: 'Remont i wykończenie', - text: 'Praktyczne porady i inspiracje', - count: '21 artykułów', - }, - { - icon: 'scale', - title: 'Prawo i podatki', - text: 'Prawo nieruchomości i podatki', - count: '17 artykułów', - }, -]; +// Krótkie opisy tematów na kafelkach /poradniki. Ikona, tytuł i slug pochodzą z GUIDE_TOPICS +// (jedno źródło prawdy), a licznik artykułów liczymy dynamicznie z API. +const GUIDE_TOPIC_TEXTS: Record = { + 'kupno-mieszkania': 'Poradniki dla kupujących', + 'kredyty-hipoteczne': 'Finansowanie zakupu', + 'wynajem': 'Porady dla najemców i wynajmujących', + 'inwestowanie': 'Analizy i strategie inwestycyjne', + 'remont-i-wykonczenie': 'Praktyczne porady i inspiracje', + 'prawo-i-podatki': 'Prawo nieruchomości i podatki', +}; + +const guideTopics = GUIDE_TOPICS.map((topic) => ({ + slug: topic.slug, + icon: topic.icon, + title: topic.title, + guide: topic.guide, + text: GUIDE_TOPIC_TEXTS[topic.slug] ?? '', +})); + +// Odmiana słowa "artykuł" po liczbie (1 artykuł, 2 artykuły, 5 artykułów). +function articleCountLabel(count: number): string { + const mod10 = count % 10; + const mod100 = count % 100; + if (count === 1) { + return '1 artykuł'; + } + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) { + return `${count} artykuły`; + } + return `${count} artykułów`; +} + +// Kategoria karty artykułu (WERSALIKAMI) -> trasa poradnika danego tematu. Dzięki temu klik w +// polecany artykuł otwiera pełny poradnik z miejscem reklamowym dla firm. +const ARTICLE_CATEGORY_ROUTES: Record = Object.fromEntries( + Object.entries(GUIDE_TOPIC_ROUTES).map(([title, route]) => [title.toUpperCase(), route]), +); const guideArticles = [ { @@ -3702,9 +3703,17 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* Trasy wymagajace zalogowania */} }> + } /> } /> = { support: { title: 'Pomoc i kontakt', subtitle: 'Wiadomości wysłane przez formularz „Napisz do nas” na stronie pomocy.' }, advertisingEnquiries: { title: 'Zapytania o reklamę', subtitle: 'Zgłoszenia firm z formularza na stronie „Jeszcze nie mamy tej usługi” (/reklama).' }, reports: { title: 'Zgłoszenia', subtitle: 'Zgłoszenia dotyczące ogłoszeń i użytkowników wymagające uwagi.' }, + articleRequests: { title: 'Poradniki - prośby', subtitle: 'Propozycje artykułów od użytkowników oczekujące na akceptację lub odrzucenie.' }, payments: { title: 'Płatności i promowanie', subtitle: 'Plany i pakiety promowania ofert, konfiguracja AutoPay oraz historia płatności.' }, stats: { title: 'Statystyki', subtitle: 'Statystyki aktywności i ruchu w serwisie.' }, settings: { title: 'Ustawienia serwisu', subtitle: 'Globalna konfiguracja serwisu Polska Lokalnie.' }, @@ -4139,6 +4149,7 @@ const ADMIN_NAV_ITEMS: { id: AdminTab; icon: string; label: string }[] = [ { id: 'support', icon: 'mail', label: 'Pomoc i kontakt' }, { id: 'advertisingEnquiries', icon: 'megaphone', label: 'Zapytania o reklamę' }, { id: 'reports', icon: 'warning', label: 'Zgłoszenia' }, + { id: 'articleRequests', icon: 'document', label: 'Poradniki - prośby' }, { id: 'leads', icon: 'user', label: 'Leady' }, { id: 'campaigns', icon: 'mail', label: 'Kampanie marketingowe' }, { id: 'payments', icon: 'credit-card', label: 'Płatności' }, @@ -4414,6 +4425,21 @@ function AdminPage() { void loadSupportMessages(); }, [loadSupportMessages]); + // Liczba propozycji artykułów oczekujących na moderację - badge przy zakładce "Poradniki - prośby". + const [pendingArticleCount, setPendingArticleCount] = useState(0); + const loadPendingArticleCount = useCallback(async () => { + try { + const data = await apiFetch<{ count: number }>('/admin/articles/pending-count'); + setPendingArticleCount(data?.count ?? 0); + } catch { + setPendingArticleCount(0); + } + }, []); + + useEffect(() => { + void loadPendingArticleCount(); + }, [loadPendingArticleCount]); + const loadAdvertisingEnquiries = useCallback(async () => { try { setAdvertisingEnquiries(await apiFetch('/admin/advertising-enquiries')); @@ -4725,6 +4751,7 @@ function AdminPage() { {item.id === 'users' && pendingVerificationCount > 0 && {pendingVerificationCount}} {item.id === 'reports' && pendingReportsCount > 0 && {pendingReportsCount}} {item.id === 'support' && newSupportMessagesCount > 0 && {newSupportMessagesCount}} + {item.id === 'articleRequests' && pendingArticleCount > 0 && {pendingArticleCount}} {item.id === 'advertisingEnquiries' && newAdvertisingEnquiriesCount > 0 && {newAdvertisingEnquiriesCount}} ))} @@ -5821,6 +5848,7 @@ function AdminPage() { {!loading && tab === 'categories' && } {!loading && tab === 'locations' && } {!loading && tab === 'promotions' && } + {!loading && tab === 'articleRequests' && } {!loading && tab === 'leads' && } {!loading && tab === 'campaigns' && } {!loading && tab === 'smtpConfig' && } @@ -15378,6 +15406,8 @@ function GuideBuyingPage() { + +
15

Najczęstsze błędy kupujących

@@ -15483,6 +15513,392 @@ function GuideBuyingPage() { ))} + + + + + + + ); +} + +// Generator PDF dla list kontrolnych poradników tematycznych. Ta sama mechanika co przy poradniku +// kupna, ale parametryzowana tytułem, podtytułem, pozycjami i nazwą pliku. +async function downloadGuideChecklistPdf(title: string, subtitle: string, items: string[], fileName: string) { + const unwrapModuleDefault = (module: T | { default: T }): T => + 'default' in Object(module) ? (module as { default: T }).default : (module as T); + const downloadBlob = (blob: Blob, name: string) => { + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = name; + link.rel = 'noopener'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + }; + + type PdfMakeRuntime = { + addFontContainer: (fontContainer: unknown) => void; + createPdf: (definition: unknown) => { getBlob: () => Promise }; + }; + + let pdfMake: PdfMakeRuntime; + try { + const pdfMakeModule = await import('pdfmake/build/pdfmake.js'); + const robotoFontModule = await import('pdfmake/build/fonts/Roboto.js'); + pdfMake = unwrapModuleDefault(pdfMakeModule as unknown as PdfMakeRuntime | { default: PdfMakeRuntime }); + pdfMake.addFontContainer(unwrapModuleDefault(robotoFontModule as unknown as { default: unknown })); + } catch (error) { + console.error('Nie udało się załadować generatora PDF', error); + window.alert('Nie udało się przygotować pliku PDF. Odśwież stronę i spróbuj ponownie.'); + return; + } + + const docDefinition = { + pageSize: 'A4', + pageMargins: [48, 48, 48, 48], + defaultStyle: { font: 'Roboto', color: '#14223a', fontSize: 11, lineHeight: 1.35 }, + content: [ + { text: 'PolskaLokalnie', color: '#0b9f5c', fontSize: 12, bold: true, margin: [0, 0, 0, 2] }, + { text: title, fontSize: 20, bold: true, margin: [0, 0, 0, 4] }, + { text: subtitle, color: '#526171', margin: [0, 0, 0, 18] }, + { ol: items.map((item) => ({ text: item, margin: [0, 0, 0, 6] })) }, + { + text: 'Materiał informacyjny. Nie stanowi porady prawnej, podatkowej ani finansowej - w razie wątpliwości skonsultuj się z odpowiednim specjalistą.', + color: '#8793a3', + fontSize: 9, + margin: [0, 22, 0, 0], + }, + ], + }; + + try { + const blob = await pdfMake.createPdf(docDefinition).getBlob(); + downloadBlob(blob, fileName); + } catch (error) { + console.error('Nie udało się wygenerować PDF', error); + window.alert('Nie udało się wygenerować pliku PDF. Spróbuj ponownie.'); + } +} + +// Renderuje pojedynczy blok treści rozdziału. Typy bloków odpowiadają tym z poradnika kupna, +// dzięki czemu artykuły tematyczne korzystają z tych samych klas CSS i wyglądają identycznie. +function GuideBlockView({ block }: { block: GuideBlock }) { + if (block.type === 'p') { + return

{block.text}

; + } + if (block.type === 'callout') { + return ( +
+ +
+ {block.title} +

{block.text}

+
+
+ ); + } + if (block.type === 'table') { + return ( +
+ + + + {block.head.map((cell) => ( + + ))} + + + + {block.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
{cell}
+ {cellIndex === 0 ? {cell} : cell} +
+
+ ); + } + if (block.type === 'twocol') { + return ( +
+ {block.cols.map((col) => ( +
+

{col.title}

+
    + {col.items.map((item) => ( +
  • {item}
  • + ))} +
+
+ ))} +
+ ); + } + // block.type === 'list' + return ( +
    + {block.items.map((item, index) => + typeof item === 'string' ? ( +
  • {item}
  • + ) : ( +
  • {item.label} - {item.text}
  • + ), + )} +
+ ); +} + +// Zapraszający kafelek reklamowy w panelu bocznym poradnika. Rezerwuje widoczne miejsce dla firm +// (biura nieruchomości, doradcy kredytowi, ekipy remontowe) i prowadzi do strony /reklama. +// Realne banery kampanii wyświetla osobno komponent BannerSlot - ten kafelek jest stałym zaproszeniem. +function GuideAdCard() { + return ( +
+ Reklama +
+ + Twoja firma może być tutaj +

Dotrzyj do osób, które właśnie planują zakup, kredyt, wynajem lub remont nieruchomości.

+
+ + Zareklamuj swoją firmę + +
+ ); +} + +// Poziomy baner reklamowy wplatany w treść poradnika - dodatkowe, wyraźne miejsce na reklamę firm. +function GuideAdBanner() { + return ( + + ); +} + +// Wspólny szablon artykułu poradnikowego (kredyty, wynajem, inwestowanie, remont, prawo i podatki). +// Treść pochodzi z guides-data.ts, a układ jest bliźniaczy do ręcznie pisanego poradnika kupna. +function GuideArticleTemplate({ content }: { content: GuideContent }) { + const scrollToId = (id: string) => { + const target = document.getElementById(id); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + const downloadPdf = () => + downloadGuideChecklistPdf(content.pdfTitle, content.pdfSubtitle, content.checklist, content.pdfFileName); + + const chapterCount = content.chapters.length; + const pad = (n: number) => String(n).padStart(2, '0'); + const toc = [ + ...content.chapters.map((chapter) => ({ id: chapter.id, label: chapter.label })), + { id: 'bledy', label: 'Najczęstsze błędy' }, + { id: 'faq', label: 'FAQ' }, + { id: 'podsumowanie', label: 'Podsumowanie' }, + ]; + + return ( +
+
+ +
+
+ + + Wróć do poradników + + + + {content.badgeLabel} + +

{content.title}

+

{content.lead}

+
+ {content.chips.map((chip) => ( + {chip.text} + ))} +
+
+
+ +
+ + +
+
+
+

{content.processTitle}

+

{content.processHint}

+
+
+ {content.process.map((step, index) => ( +
+ {index + 1} + + {step.title} + {step.text} +
+ ))} +
+
+ + {content.chapters.map((chapter, index) => ( +
+ {pad(index + 1)} +

{chapter.heading}

+ {chapter.blocks.map((block, blockIndex) => ( + + ))} +
+ ))} + +
+
+

{content.checklistTitle}

+

{content.checklistHint}

+
+
+ {content.checklist.map((item, index) => ( + + ))} +
+ +
+ + + +
+ {pad(chapterCount + 1)} +

Najczęstsze błędy

+
+ {content.mistakes.map((mistake) => ( +
+ +
+ {mistake.title} +

{mistake.text}

+
+
+ ))} +
+
+ +
+ {pad(chapterCount + 2)} +

Najczęściej zadawane pytania (FAQ)

+
+ {content.faq.map((item) => ( +
+ + {item.q} + + +

{item.a}

+
+ ))} +
+
+ +
+ {pad(chapterCount + 3)} +

Podsumowanie

+

{content.summary}

+
+ +

Ten poradnik ma charakter informacyjny i nie stanowi porady prawnej, podatkowej ani finansowej. Szczegóły potwierdzaj u odpowiedniego specjalisty - doradcy kredytowego, notariusza, doradcy podatkowego lub prawnika.

+
+
+ +
+
+

{content.ctaTitle}

+

{content.ctaText}

+
+ + {content.ctaLabel} + + +
+
+ +
@@ -15491,6 +15907,15 @@ function GuideBuyingPage() { function GuidesPage() { const navigate = useNavigate(); + // Liczba artykułów użytkowników w każdym temacie. Do tego doliczamy 1 wbudowany poradnik. + const [userCounts, setUserCounts] = useState>({}); + useEffect(() => { + let cancelled = false; + apiFetch>('/articles/counts') + .then((data) => { if (!cancelled) setUserCounts(data ?? {}); }) + .catch(() => { /* brak sieci - licznik pokaże sam wbudowany poradnik */ }); + return () => { cancelled = true; }; + }, []); return (
@@ -15528,27 +15953,18 @@ function GuidesPage() {
{guideTopics.map((topic) => { - const isBuying = topic.title === 'Kupno mieszkania'; - // Klikalny kafelek to link do poradnika; pozostale nie maja jeszcze tresci. - const content = ( - <> + // Klik w temat otwiera listę poradników danego tematu. Licznik = 1 wbudowany + // poradnik + artykuły dodane przez użytkowników (z API). + const total = 1 + (userCounts[topic.slug] ?? 0); + return ( +

{topic.title}

{topic.text}

- {topic.count} - - ); - - return isBuying ? ( - - {content} + {articleCountLabel(total)} - ) : ( -
- {content} -
); })}
@@ -15561,7 +15977,11 @@ function GuidesPage() {
{guideArticles.map((article) => ( -
+
{article.views}
-
+ ))} @@ -15605,25 +16025,17 @@ function GuidesPage() {