Phần Post đã thiết kế xong chỉ cần sửa lại tempalte nữa là được

This commit is contained in:
2026-06-28 16:25:51 +07:00
parent 38d6b8234a
commit 0326535eb6
64 changed files with 13477 additions and 1603 deletions
@@ -14,13 +14,12 @@ import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.h2console.autoconfigure.H2ConsoleAutoConfiguration;
import org.springframework.boot.liquibase.autoconfigure.LiquibaseProperties;
import org.springframework.core.env.Environment;
import tech.jhipster.config.DefaultProfileUtil;
import tech.jhipster.config.JHipsterConstants;
@SpringBootApplication(exclude = { H2ConsoleAutoConfiguration.class })
@SpringBootApplication
@EnableConfigurationProperties({ LiquibaseProperties.class, ApplicationProperties.class })
public class SisvietnamvnApp {
@@ -5,7 +5,6 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.h2console.autoconfigure.H2ConsoleProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
@@ -14,13 +13,11 @@ import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import tech.jhipster.config.JHipsterConstants;
import tech.jhipster.config.h2.H2ConfigurationHelper;
@Configuration
@EnableJpaRepositories({ "com.sisvietnamvn.web.repository" })
@EnableJpaAuditing(auditorAwareRef = "springSecurityAuditorAware")
@EnableTransactionManagement
@EnableConfigurationProperties(H2ConsoleProperties.class)
public class DatabaseConfiguration {
private static final Logger LOG = LoggerFactory.getLogger(DatabaseConfiguration.class);
@@ -30,33 +27,4 @@ public class DatabaseConfiguration {
public DatabaseConfiguration(Environment env) {
this.env = env;
}
/**
* Open the TCP port for the H2 database, so it is available remotely.
*
* @return the H2 database TCP server.
* @throws SQLException if the server failed to start.
*/
@Bean(initMethod = "start", destroyMethod = "stop")
@Profile(JHipsterConstants.SPRING_PROFILE_DEVELOPMENT)
@ConditionalOnProperty(prefix = "spring.h2.console", name = "enabled", havingValue = "true")
public Object h2TCPServer() throws SQLException {
String port = getValidPortForH2();
LOG.debug("H2 database is available on port {}", port);
return H2ConfigurationHelper.createServer(port);
}
private String getValidPortForH2() {
var port = Integer.parseInt(env.getProperty("server.port"));
if (port < 10000) {
port = 10000 + port;
} else {
if (port < 63536) {
port = port + 2000;
} else {
port = port - 2000;
}
}
return String.valueOf(port);
}
}
@@ -46,7 +46,7 @@ public class SecurityConfiguration {
authz
.requestMatchers(HttpMethod.GET, "/", "/about", "/flex-finish", "/tin-tuc", "/tin-tuc/**", "/lien-he",
"/manage/**", "/css/**", "/images/**", "/js/**", "/UMass*/**", "/Undergraduate*/**",
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/api/manage/snippets/**", "/page/**",
"/favicon.ico", "/favicons/**", "/flex-finish/**", "/contact-us/**", "/uploads/**", "/api/manage/snippets/**", "/page/**", "/news/article/**", "/post/**", "/error",
"/about-us", "/specialty", "/doctor", "/service", "/health-library", "/news-and-events", "/patient-support", "/medical-expert", "/umcers", "/bidding", "/contact-us")
.permitAll()
.requestMatchers(HttpMethod.POST, "/manage/**", "/api/manage/media/upload").permitAll()
@@ -0,0 +1,54 @@
package com.sisvietnamvn.web.controller;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.repository.PostRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
@Controller
@RequestMapping("/post")
public class PostController {
private final PostRepository postRepository;
public PostController(PostRepository postRepository) {
this.postRepository = postRepository;
}
@GetMapping("/{slug}")
public String viewPost(@PathVariable String slug, Model model) {
Post post = postRepository.findBySlug(slug)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
// Only allow viewing published posts (unless admin, but for now just check published)
if (post.getStatus() != PageStatus.PUBLISHED) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
model.addAttribute("post", post);
// Safely format the Instant to avoid Thymeleaf parsing errors
if (post.getCreatedDate() != null) {
String formattedDate = java.time.format.DateTimeFormatter.ofPattern("MMMM dd, yyyy")
.withZone(java.time.ZoneId.systemDefault())
.format(post.getCreatedDate());
model.addAttribute("formattedDate", formattedDate);
}
switch (post.getLayout()) {
case SIDEBAR:
return "posts/sidebar";
case FULL_WIDTH:
return "posts/full-width";
case STANDARD:
default:
return "posts/standard";
}
}
}
@@ -0,0 +1,141 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Category;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.service.CategoryService;
import jakarta.validation.Valid;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controller for managing post Categories in the admin panel.
* Uses a WordPress-style split layout: form on the left, table on the right.
*/
@Controller
@RequestMapping("/manage/posts/categories")
public class ManageCategoryController {
private static final Logger LOG = LoggerFactory.getLogger(ManageCategoryController.class);
private final CategoryService categoryService;
private final HookManager hookManager;
public ManageCategoryController(CategoryService categoryService, HookManager hookManager) {
this.categoryService = categoryService;
this.hookManager = hookManager;
}
@ModelAttribute
public void adminInit() {
hookManager.doAction("admin_init");
}
/**
* GET /manage/posts/categories — List all categories with inline create form.
*/
@GetMapping
public String listCategories(Model model) {
LOG.debug("Request to list all categories");
populateModel(model, new Category(), true);
return "manage/posts/categories";
}
/**
* POST /manage/posts/categories — Save a new category.
*/
@PostMapping
public String createCategory(@Valid @ModelAttribute("category") Category category,
BindingResult bindingResult,
@RequestParam(value = "parentId", required = false) Long parentId,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Category : {}", category);
if (bindingResult.hasErrors()) {
populateModel(model, category, true);
return "manage/posts/categories";
}
if (parentId != null) {
categoryService.findById(parentId).ifPresent(category::setParent);
}
categoryService.save(category);
redirectAttributes.addFlashAttribute("successMessage", "Category created successfully!");
return "redirect:/manage/posts/categories";
}
/**
* GET /manage/posts/categories/{id}/edit — Show the edit form for a category.
*/
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
LOG.debug("Request to show edit form for Category : {}", id);
Optional<Category> categoryOptional = categoryService.findById(id);
if (categoryOptional.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Category not found.");
return "redirect:/manage/posts/categories";
}
populateModel(model, categoryOptional.get(), false);
return "manage/posts/categories";
}
/**
* POST /manage/posts/categories/{id} — Update an existing category.
*/
@PostMapping("/{id}")
public String updateCategory(@PathVariable Long id,
@Valid @ModelAttribute("category") Category category,
BindingResult bindingResult,
@RequestParam(value = "parentId", required = false) Long parentId,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Category : {}", id);
if (bindingResult.hasErrors()) {
populateModel(model, category, false);
return "manage/posts/categories";
}
category.setId(id);
if (parentId != null && !parentId.equals(id)) {
categoryService.findById(parentId).ifPresent(category::setParent);
} else {
category.setParent(null);
}
categoryService.save(category);
redirectAttributes.addFlashAttribute("successMessage", "Category updated successfully!");
return "redirect:/manage/posts/categories";
}
/**
* POST /manage/posts/categories/{id}/delete — Delete a category.
*/
@PostMapping("/{id}/delete")
public String deleteCategory(@PathVariable Long id, RedirectAttributes redirectAttributes) {
LOG.debug("Request to delete Category : {}", id);
try {
categoryService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "Category deleted successfully!");
} catch (IllegalStateException e) {
redirectAttributes.addFlashAttribute("errorMessage", e.getMessage());
}
return "redirect:/manage/posts/categories";
}
/**
* Populate common model attributes for the categories view.
*/
private void populateModel(Model model, Category category, boolean isNew) {
model.addAttribute("category", category);
model.addAttribute("isNew", isNew);
model.addAttribute("allCategories", categoryService.findAll());
// Build a map of category ID -> post count for the table
java.util.Map<Long, Long> postCounts = new java.util.HashMap<>();
for (Category c : categoryService.findAll()) {
postCounts.put(c.getId(), categoryService.countPosts(c.getId()));
}
model.addAttribute("postCounts", postCounts);
}
}
@@ -0,0 +1,182 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.PostLayout;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.service.CategoryService;
import com.sisvietnamvn.web.service.PostService;
import com.sisvietnamvn.web.service.TagService;
import jakarta.validation.Valid;
import java.util.Optional;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controller for managing blog Posts in the admin panel.
* Provides CRUD operations with category and tag management.
*/
@Controller
@RequestMapping("/manage/posts")
public class ManagePostController {
private static final Logger LOG = LoggerFactory.getLogger(ManagePostController.class);
private final PostService postService;
private final CategoryService categoryService;
private final TagService tagService;
private final HookManager hookManager;
public ManagePostController(PostService postService,
CategoryService categoryService,
TagService tagService,
HookManager hookManager) {
this.postService = postService;
this.categoryService = categoryService;
this.tagService = tagService;
this.hookManager = hookManager;
}
@ModelAttribute
public void adminInit() {
hookManager.doAction("admin_init");
}
/**
* GET /manage/posts — List all posts with optional filters.
*/
@GetMapping
public String listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "status", required = false) String statusStr,
Model model) {
LOG.debug("Request to list all posts (categoryId={}, status={})", categoryId, statusStr);
PageStatus status = null;
if (statusStr != null && !statusStr.isBlank()) {
try {
status = PageStatus.valueOf(statusStr);
} catch (IllegalArgumentException e) {
LOG.warn("Invalid status filter: {}", statusStr);
}
}
model.addAttribute("posts", postService.findFiltered(categoryId, status));
model.addAttribute("categories", categoryService.findAll());
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("selectedCategoryId", categoryId);
model.addAttribute("selectedStatus", statusStr);
return "manage/posts/list";
}
/**
* GET /manage/posts/new — Show the "Add New Post" form.
*/
@GetMapping("/new")
public String showCreateForm(Model model) {
LOG.debug("Request to show create post form");
Post post = new Post();
post.setStatus(PageStatus.DRAFT);
populateFormModel(model, post, "", true);
return "manage/posts/form";
}
/**
* POST /manage/posts/create — Save a new post.
*/
@PostMapping("/create")
public String createPost(@Valid @ModelAttribute("post") Post post,
BindingResult bindingResult,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "tagNames", required = false, defaultValue = "") String tagNames,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Post : {}", post);
if (bindingResult.hasErrors()) {
populateFormModel(model, post, tagNames, true);
return "manage/posts/form";
}
if (categoryId != null) {
categoryService.findById(categoryId).ifPresent(post::setCategory);
}
postService.saveWithTags(post, tagNames);
redirectAttributes.addFlashAttribute("successMessage", "Post created successfully!");
return "redirect:/manage/posts";
}
/**
* GET /manage/posts/{id}/edit — Show the "Edit Post" form.
*/
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
LOG.debug("Request to show edit form for Post : {}", id);
Optional<Post> postOptional = postService.findById(id);
if (postOptional.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Post not found.");
return "redirect:/manage/posts";
}
Post post = postOptional.get();
String tagNames = post.getTags().stream()
.map(Tag::getName)
.collect(Collectors.joining(", "));
populateFormModel(model, post, tagNames, false);
return "manage/posts/form";
}
/**
* POST /manage/posts/{id} — Update an existing post.
*/
@PostMapping("/{id}")
public String updatePost(@PathVariable Long id,
@Valid @ModelAttribute("post") Post post,
BindingResult bindingResult,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "tagNames", required = false, defaultValue = "") String tagNames,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Post : {}", id);
if (bindingResult.hasErrors()) {
populateFormModel(model, post, tagNames, false);
return "manage/posts/form";
}
post.setId(id);
if (categoryId != null) {
categoryService.findById(categoryId).ifPresent(post::setCategory);
} else {
post.setCategory(null);
}
postService.saveWithTags(post, tagNames);
redirectAttributes.addFlashAttribute("successMessage", "Post updated successfully!");
return "redirect:/manage/posts";
}
/**
* POST /manage/posts/{id}/delete — Delete a post.
*/
@PostMapping("/{id}/delete")
public String deletePost(@PathVariable Long id, RedirectAttributes redirectAttributes) {
LOG.debug("Request to delete Post : {}", id);
postService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "Post deleted successfully!");
return "redirect:/manage/posts";
}
/**
* Populate common model attributes for the post form.
*/
private void populateFormModel(Model model, Post post, String tagNames, boolean isNew) {
model.addAttribute("post", post);
model.addAttribute("tagNames", tagNames);
model.addAttribute("isNew", isNew);
model.addAttribute("categories", categoryService.findAll());
model.addAttribute("statuses", PageStatus.values());
model.addAttribute("layouts", PostLayout.values());
model.addAttribute("allTags", tagService.findAll());
}
}
@@ -0,0 +1,127 @@
package com.sisvietnamvn.web.controller.manage;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.service.TagService;
import jakarta.validation.Valid;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
/**
* Controller for managing post Tags in the admin panel.
* Uses a WordPress-style split layout: form on the left, table on the right.
*/
@Controller
@RequestMapping("/manage/posts/tags")
public class ManageTagController {
private static final Logger LOG = LoggerFactory.getLogger(ManageTagController.class);
private final TagService tagService;
private final HookManager hookManager;
public ManageTagController(TagService tagService, HookManager hookManager) {
this.tagService = tagService;
this.hookManager = hookManager;
}
@ModelAttribute
public void adminInit() {
hookManager.doAction("admin_init");
}
/**
* GET /manage/posts/tags — List all tags with inline create form.
*/
@GetMapping
public String listTags(Model model) {
LOG.debug("Request to list all tags");
populateModel(model, new Tag(), true);
return "manage/posts/tags";
}
/**
* POST /manage/posts/tags — Save a new tag.
*/
@PostMapping
public String createTag(@Valid @ModelAttribute("tag") Tag tag,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to create Tag : {}", tag);
if (bindingResult.hasErrors()) {
populateModel(model, tag, true);
return "manage/posts/tags";
}
tagService.save(tag);
redirectAttributes.addFlashAttribute("successMessage", "Tag created successfully!");
return "redirect:/manage/posts/tags";
}
/**
* GET /manage/posts/tags/{id}/edit — Show the edit form for a tag.
*/
@GetMapping("/{id}/edit")
public String showEditForm(@PathVariable Long id, Model model, RedirectAttributes redirectAttributes) {
LOG.debug("Request to show edit form for Tag : {}", id);
Optional<Tag> tagOptional = tagService.findById(id);
if (tagOptional.isEmpty()) {
redirectAttributes.addFlashAttribute("errorMessage", "Tag not found.");
return "redirect:/manage/posts/tags";
}
populateModel(model, tagOptional.get(), false);
return "manage/posts/tags";
}
/**
* POST /manage/posts/tags/{id} — Update an existing tag.
*/
@PostMapping("/{id}")
public String updateTag(@PathVariable Long id,
@Valid @ModelAttribute("tag") Tag tag,
BindingResult bindingResult,
Model model,
RedirectAttributes redirectAttributes) {
LOG.debug("Request to update Tag : {}", id);
if (bindingResult.hasErrors()) {
populateModel(model, tag, false);
return "manage/posts/tags";
}
tag.setId(id);
tagService.save(tag);
redirectAttributes.addFlashAttribute("successMessage", "Tag updated successfully!");
return "redirect:/manage/posts/tags";
}
/**
* POST /manage/posts/tags/{id}/delete — Delete a tag.
*/
@PostMapping("/{id}/delete")
public String deleteTag(@PathVariable Long id, RedirectAttributes redirectAttributes) {
LOG.debug("Request to delete Tag : {}", id);
tagService.delete(id);
redirectAttributes.addFlashAttribute("successMessage", "Tag deleted successfully!");
return "redirect:/manage/posts/tags";
}
/**
* Populate common model attributes for the tags view.
*/
private void populateModel(Model model, Tag tag, boolean isNew) {
model.addAttribute("tag", tag);
model.addAttribute("isNew", isNew);
model.addAttribute("allTags", tagService.findAll());
// Build a map of tag ID -> post count for the table
java.util.Map<Long, Long> postCounts = new java.util.HashMap<>();
for (Tag t : tagService.findAll()) {
postCounts.put(t.getId(), tagService.countPosts(t.getId()));
}
model.addAttribute("postCounts", postCounts);
}
}
@@ -74,4 +74,12 @@ public abstract class AbstractAuditingEntity<T> implements Serializable {
public void setLastModifiedDate(Instant lastModifiedDate) {
this.lastModifiedDate = lastModifiedDate;
}
public java.util.Date getCreatedDateAsDate() {
return createdDate != null ? java.util.Date.from(createdDate) : null;
}
public java.util.Date getLastModifiedDateAsDate() {
return lastModifiedDate != null ? java.util.Date.from(lastModifiedDate) : null;
}
}
@@ -0,0 +1,110 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.util.Objects;
/**
* A Category entity for classifying blog posts.
* Supports hierarchical structure via self-referencing parent relationship.
* Maps to the "sis_category" database table.
*/
@Entity
@Table(name = "sis_category")
public class Category extends AbstractAuditingEntity<Long> {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@NotNull
@Size(max = 255)
@Column(name = "name", length = 255, nullable = false)
private String name;
@NotNull
@Size(max = 255)
@Column(name = "slug", length = 255, nullable = false, unique = true)
private String slug;
@Size(max = 1000)
@Column(name = "description", length = 1000)
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
// --- Getters and Setters ---
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Category getParent() {
return parent;
}
public void setParent(Category parent) {
this.parent = parent;
}
// --- equals, hashCode, toString ---
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Category category)) return false;
return id != null && id.equals(category.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Category{" +
"id=" + getId() +
", name='" + getName() + "'" +
", slug='" + getSlug() + "'" +
", description='" + getDescription() + "'" +
"}";
}
}
@@ -0,0 +1,189 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
/**
* A Post entity representing a blog/news article.
* Maps to the "sis_post" database table.
*/
@Entity
@Table(name = "sis_post")
public class Post extends AbstractAuditingEntity<Long> {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@NotNull
@Size(max = 255)
@Column(name = "title", length = 255, nullable = false)
private String title;
@NotNull
@Size(max = 255)
@Column(name = "slug", length = 255, nullable = false, unique = true)
private String slug;
@Lob
@Column(name = "content")
private String content;
@Size(max = 1000)
@Column(name = "excerpt", length = 1000)
private String excerpt;
@Size(max = 500)
@Column(name = "featured_image", length = 500)
private String featuredImage;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
private PageStatus status = PageStatus.DRAFT;
@Size(max = 500)
@Column(name = "meta_description", length = 500)
private String metaDescription;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "layout", length = 20, nullable = false)
private PostLayout layout = PostLayout.STANDARD;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "sis_post_tag",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
// --- Getters and Setters ---
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getExcerpt() {
return excerpt;
}
public void setExcerpt(String excerpt) {
this.excerpt = excerpt;
}
public String getFeaturedImage() {
return featuredImage;
}
public void setFeaturedImage(String featuredImage) {
this.featuredImage = featuredImage;
}
public PageStatus getStatus() {
return status;
}
public void setStatus(PageStatus status) {
this.status = status;
}
public String getMetaDescription() {
return metaDescription;
}
public void setMetaDescription(String metaDescription) {
this.metaDescription = metaDescription;
}
public PostLayout getLayout() {
return layout;
}
public void setLayout(PostLayout layout) {
this.layout = layout;
}
public Category getCategory() {
return category;
}
public void setCategory(Category category) {
this.category = category;
}
public Set<Tag> getTags() {
return tags;
}
public void setTags(Set<Tag> tags) {
this.tags = tags;
}
// --- equals, hashCode, toString ---
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Post post)) return false;
return id != null && id.equals(post.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Post{" +
"id=" + getId() +
", title='" + getTitle() + "'" +
", slug='" + getSlug() + "'" +
", status='" + getStatus() + "'" +
"}";
}
}
@@ -0,0 +1,10 @@
package com.sisvietnamvn.web.domain;
/**
* Enumeration for Post Layout options.
*/
public enum PostLayout {
STANDARD,
SIDEBAR,
FULL_WIDTH
}
@@ -0,0 +1,84 @@
package com.sisvietnamvn.web.domain;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.io.Serial;
import java.util.Objects;
/**
* A Tag entity for labeling blog posts with keywords.
* Maps to the "sis_tag" database table.
*/
@Entity
@Table(name = "sis_tag")
public class Tag extends AbstractAuditingEntity<Long> {
@Serial
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "sequenceGenerator")
@SequenceGenerator(name = "sequenceGenerator")
private Long id;
@NotNull
@Size(max = 255)
@Column(name = "name", length = 255, nullable = false, unique = true)
private String name;
@NotNull
@Size(max = 255)
@Column(name = "slug", length = 255, nullable = false, unique = true)
private String slug;
// --- Getters and Setters ---
@Override
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSlug() {
return slug;
}
public void setSlug(String slug) {
this.slug = slug;
}
// --- equals, hashCode, toString ---
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Tag tag)) return false;
return id != null && id.equals(tag.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Tag{" +
"id=" + getId() +
", name='" + getName() + "'" +
", slug='" + getSlug() + "'" +
"}";
}
}
@@ -0,0 +1,44 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.Category;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* Spring Data JPA repository for the {@link Category} entity.
*/
@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
/**
* Find all categories ordered by name ascending.
*/
List<Category> findAllByOrderByNameAsc();
/**
* Find a category by its URL-friendly slug.
*/
Optional<Category> findBySlug(String slug);
/**
* Check if a slug already exists (for uniqueness validation on create).
*/
boolean existsBySlug(String slug);
/**
* Check if a slug already exists for a different category (for edit validation).
*/
boolean existsBySlugAndIdNot(String slug, Long id);
/**
* Find all top-level categories (no parent).
*/
List<Category> findByParentIsNullOrderByNameAsc();
/**
* Find child categories of a given parent.
*/
List<Category> findByParentIdOrderByNameAsc(Long parentId);
}
@@ -0,0 +1,80 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
/**
* Spring Data JPA repository for the {@link Post} entity.
*/
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
/**
* Find all posts ordered by creation date descending (newest first).
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
List<Post> findAllByOrderByCreatedDateDesc();
/**
* Find a post by its ID with eagerly fetched category and tags.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
Optional<Post> findById(Long id);
/**
* Find a post by its URL-friendly slug.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
Optional<Post> findBySlug(String slug);
/**
* Find all posts with a given publication status.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
List<Post> findByStatusOrderByCreatedDateDesc(PageStatus status);
/**
* Find all posts in a given category.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
List<Post> findByCategoryIdOrderByCreatedDateDesc(Long categoryId);
/**
* Find all posts in a given category with a given status.
*/
@org.springframework.data.jpa.repository.EntityGraph(attributePaths = {"category", "tags"})
List<Post> findByCategoryIdAndStatusOrderByCreatedDateDesc(Long categoryId, PageStatus status);
/**
* Count the number of posts in a given category.
*/
long countByCategoryId(Long categoryId);
/**
* Count the number of posts associated with a given tag.
*/
@Query("SELECT COUNT(p) FROM Post p JOIN p.tags t WHERE t.id = :tagId")
long countByTagId(@Param("tagId") Long tagId);
/**
* Check if a slug already exists (for uniqueness validation on create).
*/
boolean existsBySlug(String slug);
/**
* Check if a slug already exists for a different post (for edit validation).
*/
boolean existsBySlugAndIdNot(String slug, Long id);
/**
* Find all posts that have a specific tag.
*/
@Query("SELECT p FROM Post p JOIN p.tags t WHERE t.id = :tagId ORDER BY p.createdDate DESC")
List<Post> findByTagId(@Param("tagId") Long tagId);
}
@@ -0,0 +1,44 @@
package com.sisvietnamvn.web.repository;
import com.sisvietnamvn.web.domain.Tag;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* Spring Data JPA repository for the {@link Tag} entity.
*/
@Repository
public interface TagRepository extends JpaRepository<Tag, Long> {
/**
* Find all tags ordered by name ascending.
*/
List<Tag> findAllByOrderByNameAsc();
/**
* Find a tag by its URL-friendly slug.
*/
Optional<Tag> findBySlug(String slug);
/**
* Find a tag by its exact name.
*/
Optional<Tag> findByName(String name);
/**
* Search tags by name containing the given string (for autocomplete).
*/
List<Tag> findByNameContainingIgnoreCase(String name);
/**
* Check if a slug already exists (for uniqueness validation on create).
*/
boolean existsBySlug(String slug);
/**
* Check if a slug already exists for a different tag (for edit validation).
*/
boolean existsBySlugAndIdNot(String slug, Long id);
}
@@ -0,0 +1,123 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Category;
import com.sisvietnamvn.web.repository.CategoryRepository;
import com.sisvietnamvn.web.repository.PostRepository;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Service class for managing post categories.
*/
@Service
@Transactional
public class CategoryService {
private static final Logger LOG = LoggerFactory.getLogger(CategoryService.class);
private final CategoryRepository categoryRepository;
private final PostRepository postRepository;
public CategoryService(CategoryRepository categoryRepository, PostRepository postRepository) {
this.categoryRepository = categoryRepository;
this.postRepository = postRepository;
}
/**
* Get all categories sorted by name.
*/
@Transactional(readOnly = true)
public List<Category> findAll() {
LOG.debug("Request to get all Categories");
return categoryRepository.findAllByOrderByNameAsc();
}
/**
* Get a single category by ID.
*/
@Transactional(readOnly = true)
public Optional<Category> findById(Long id) {
LOG.debug("Request to get Category : {}", id);
return categoryRepository.findById(id);
}
/**
* Get a single category by its URL slug.
*/
@Transactional(readOnly = true)
public Optional<Category> findBySlug(String slug) {
LOG.debug("Request to get Category by slug : {}", slug);
return categoryRepository.findBySlug(slug);
}
/**
* Get top-level categories (no parent).
*/
@Transactional(readOnly = true)
public List<Category> findTopLevel() {
LOG.debug("Request to get top-level Categories");
return categoryRepository.findByParentIsNullOrderByNameAsc();
}
/**
* Count the number of posts in a category.
*/
@Transactional(readOnly = true)
public long countPosts(Long categoryId) {
return postRepository.countByCategoryId(categoryId);
}
/**
* Save a category (create or update).
* Auto-generates a URL slug from the name if the slug is empty.
*/
public Category save(Category category) {
LOG.debug("Request to save Category : {}", category);
if (category.getSlug() == null || category.getSlug().isBlank()) {
category.setSlug(PageService.generateSlug(category.getName()));
}
// Ensure slug uniqueness
String baseSlug = category.getSlug();
String candidateSlug = baseSlug;
int counter = 1;
while (isSlugTaken(candidateSlug, category.getId())) {
candidateSlug = baseSlug + "-" + counter;
counter++;
}
category.setSlug(candidateSlug);
return categoryRepository.save(category);
}
/**
* Delete a category by ID.
* @throws IllegalStateException if the category still has posts assigned to it.
*/
public void delete(Long id) {
LOG.debug("Request to delete Category : {}", id);
long postCount = postRepository.countByCategoryId(id);
if (postCount > 0) {
throw new IllegalStateException(
"Cannot delete category: " + postCount + " post(s) are still assigned to it. " +
"Please reassign or delete those posts first."
);
}
categoryRepository.deleteById(id);
}
/**
* Check if a slug is already taken by another category.
*/
private boolean isSlugTaken(String slug, Long currentCategoryId) {
if (currentCategoryId == null) {
return categoryRepository.existsBySlug(slug);
}
return categoryRepository.existsBySlugAndIdNot(slug, currentCategoryId);
}
}
@@ -0,0 +1,174 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.PageStatus;
import com.sisvietnamvn.web.domain.Post;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.hook.HookManager;
import com.sisvietnamvn.web.repository.PostRepository;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Service class for managing blog posts.
*/
@Service
@Transactional
public class PostService {
private static final Logger LOG = LoggerFactory.getLogger(PostService.class);
private final PostRepository postRepository;
private final TagService tagService;
private final HookManager hookManager;
public PostService(PostRepository postRepository, TagService tagService, HookManager hookManager) {
this.postRepository = postRepository;
this.tagService = tagService;
this.hookManager = hookManager;
}
/**
* Get all posts ordered by creation date (newest first).
*/
@Transactional(readOnly = true)
public List<Post> findAll() {
LOG.debug("Request to get all Posts");
return postRepository.findAllByOrderByCreatedDateDesc();
}
/**
* Get a single post by ID.
*/
@Transactional(readOnly = true)
public Optional<Post> findById(Long id) {
LOG.debug("Request to get Post : {}", id);
return postRepository.findById(id);
}
/**
* Get a single post by its URL slug.
*/
@Transactional(readOnly = true)
public Optional<Post> findBySlug(String slug) {
LOG.debug("Request to get Post by slug : {}", slug);
return postRepository.findBySlug(slug);
}
/**
* Find all posts with a specific status.
*/
@Transactional(readOnly = true)
public List<Post> findByStatus(PageStatus status) {
LOG.debug("Request to get Posts by status : {}", status);
return postRepository.findByStatusOrderByCreatedDateDesc(status);
}
/**
* Find all posts in a specific category.
*/
@Transactional(readOnly = true)
public List<Post> findByCategoryId(Long categoryId) {
LOG.debug("Request to get Posts by category : {}", categoryId);
return postRepository.findByCategoryIdOrderByCreatedDateDesc(categoryId);
}
/**
* Find all posts filtered by category and/or status.
*/
@Transactional(readOnly = true)
public List<Post> findFiltered(Long categoryId, PageStatus status) {
LOG.debug("Request to get Posts filtered by categoryId={}, status={}", categoryId, status);
if (categoryId != null && status != null) {
return postRepository.findByCategoryIdAndStatusOrderByCreatedDateDesc(categoryId, status);
} else if (categoryId != null) {
return postRepository.findByCategoryIdOrderByCreatedDateDesc(categoryId);
} else if (status != null) {
return postRepository.findByStatusOrderByCreatedDateDesc(status);
}
return postRepository.findAllByOrderByCreatedDateDesc();
}
/**
* Save a post (create or update).
* Auto-generates a URL slug from the title if the slug is empty.
*/
public Post save(Post post) {
LOG.debug("Request to save Post : {}", post);
// Hook: pre_save_post filter
post = hookManager.applyFilters("pre_save_post", post);
if (post.getSlug() == null || post.getSlug().isBlank()) {
post.setSlug(PageService.generateSlug(post.getTitle()));
}
// Ensure slug uniqueness
String baseSlug = post.getSlug();
String candidateSlug = baseSlug;
int counter = 1;
while (isSlugTaken(candidateSlug, post.getId())) {
candidateSlug = baseSlug + "-" + counter;
counter++;
}
// Hook: post_slug filter
candidateSlug = hookManager.applyFilters("post_slug", candidateSlug);
post.setSlug(candidateSlug);
Post savedPost = postRepository.save(post);
// Hook: save_post action
hookManager.doAction("save_post", savedPost);
return savedPost;
}
/**
* Save a post and process comma-separated tag names.
* Auto-creates tags that don't exist yet.
*
* @param post the post entity
* @param tagNames comma-separated tag names (e.g. "news, health, update")
*/
public Post saveWithTags(Post post, String tagNames) {
Set<Tag> tags = new HashSet<>();
if (tagNames != null && !tagNames.isBlank()) {
String[] names = tagNames.split(",");
for (String name : names) {
String trimmed = name.trim();
if (!trimmed.isEmpty()) {
tags.add(tagService.findOrCreateByName(trimmed));
}
}
}
post.setTags(tags);
return save(post);
}
/**
* Delete a post by ID.
*/
public void delete(Long id) {
LOG.debug("Request to delete Post : {}", id);
postRepository.deleteById(id);
// Hook: deleted_post action
hookManager.doAction("deleted_post", id);
}
/**
* Check if a slug is already taken by another post.
*/
private boolean isSlugTaken(String slug, Long currentPostId) {
if (currentPostId == null) {
return postRepository.existsBySlug(slug);
}
return postRepository.existsBySlugAndIdNot(slug, currentPostId);
}
}
@@ -0,0 +1,131 @@
package com.sisvietnamvn.web.service;
import com.sisvietnamvn.web.domain.Tag;
import com.sisvietnamvn.web.repository.PostRepository;
import com.sisvietnamvn.web.repository.TagRepository;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Service class for managing post tags.
*/
@Service
@Transactional
public class TagService {
private static final Logger LOG = LoggerFactory.getLogger(TagService.class);
private final TagRepository tagRepository;
private final PostRepository postRepository;
public TagService(TagRepository tagRepository, PostRepository postRepository) {
this.tagRepository = tagRepository;
this.postRepository = postRepository;
}
/**
* Get all tags sorted by name.
*/
@Transactional(readOnly = true)
public List<Tag> findAll() {
LOG.debug("Request to get all Tags");
return tagRepository.findAllByOrderByNameAsc();
}
/**
* Get a single tag by ID.
*/
@Transactional(readOnly = true)
public Optional<Tag> findById(Long id) {
LOG.debug("Request to get Tag : {}", id);
return tagRepository.findById(id);
}
/**
* Get a single tag by its URL slug.
*/
@Transactional(readOnly = true)
public Optional<Tag> findBySlug(String slug) {
LOG.debug("Request to get Tag by slug : {}", slug);
return tagRepository.findBySlug(slug);
}
/**
* Find or create a tag by name.
* Used when processing comma-separated tag input from the post editor.
*/
public Tag findOrCreateByName(String name) {
String trimmed = name.trim();
LOG.debug("Request to find or create Tag by name : {}", trimmed);
return tagRepository.findByName(trimmed)
.orElseGet(() -> {
Tag tag = new Tag();
tag.setName(trimmed);
tag.setSlug(PageService.generateSlug(trimmed));
return save(tag);
});
}
/**
* Search tags by name (for autocomplete).
*/
@Transactional(readOnly = true)
public List<Tag> searchByName(String query) {
LOG.debug("Request to search Tags by name : {}", query);
return tagRepository.findByNameContainingIgnoreCase(query);
}
/**
* Count the number of posts associated with a tag.
*/
@Transactional(readOnly = true)
public long countPosts(Long tagId) {
return postRepository.countByTagId(tagId);
}
/**
* Save a tag (create or update).
* Auto-generates a URL slug from the name if the slug is empty.
*/
public Tag save(Tag tag) {
LOG.debug("Request to save Tag : {}", tag);
if (tag.getSlug() == null || tag.getSlug().isBlank()) {
tag.setSlug(PageService.generateSlug(tag.getName()));
}
// Ensure slug uniqueness
String baseSlug = tag.getSlug();
String candidateSlug = baseSlug;
int counter = 1;
while (isSlugTaken(candidateSlug, tag.getId())) {
candidateSlug = baseSlug + "-" + counter;
counter++;
}
tag.setSlug(candidateSlug);
return tagRepository.save(tag);
}
/**
* Delete a tag by ID.
*/
public void delete(Long id) {
LOG.debug("Request to delete Tag : {}", id);
tagRepository.deleteById(id);
}
/**
* Check if a slug is already taken by another tag.
*/
private boolean isSlugTaken(String slug, Long currentTagId) {
if (currentTagId == null) {
return tagRepository.existsBySlug(slug);
}
return tagRepository.existsBySlugAndIdNot(slug, currentTagId);
}
}