poradniki
This commit is contained in:
@@ -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(" ", " ").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()
|
||||
|
||||
Reference in New Issue
Block a user