Athena — mahmoud-consultancy/archive/old-docs/sprints/SPRINT5_DETAILED_EXECUTION_PLAN.md


title: Sprint 5 - Knowledge Base Detailed Execution Plan date: 2025-10-15 status: Active - Ready to Execute tags: [sprint5, knowledge-base, execution, implementation]

Sprint 5: Knowledge Base - Detailed Execution Plan

Duration: 4 weeks (Nov 4 - Nov 29, 2025) Goal: Production-ready Knowledge Base with 20+ articles


Week 1: Backend Development (Nov 4-8)

Day 1 - Monday, Nov 4: Database Entities

Morning (4 hours): Entity Creation

File: backend/src/main/java/nl/glorylabs/entity/Article.java

package nl.glorylabs.entity;

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "articles")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 200)
    private String title;

    @Column(nullable = false, unique = true, length = 250)
    private String slug; // URL-friendly: "wat-is-detachering"

    @Column(length = 500)
    private String summary; // Short description for cards

    @Lob
    @Column(nullable = false, columnDefinition = "TEXT")
    private String content; // Markdown content

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private ArticleCategory category;

    @Column(length = 100)
    private String author;

    @Column(name = "published_at")
    private LocalDateTime publishedAt;

    @Column(name = "updated_at")
    private LocalDateTime updatedAt;

    @Column(nullable = false)
    private boolean published = false;

    @Column(name = "view_count")
    private int viewCount = 0;

    @Column(name = "avg_rating")
    private double avgRating = 0.0;

    @ElementCollection
    @CollectionTable(name = "article_tags", joinColumns = @JoinColumn(name = "article_id"))
    @Column(name = "tag")
    private List<String> tags = new ArrayList<>();

    @Column(name = "meta_description", length = 160)
    private String metaDescription; // SEO

    @Column(name = "featured_image")
    private String featuredImage; // URL to image

    @Column(name = "reading_time")
    private int readingTime; // Minutes

    @PrePersist
    protected void onCreate() {
        updatedAt = LocalDateTime.now();
        if (published && publishedAt == null) {
            publishedAt = LocalDateTime.now();
        }
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }
}

File: backend/src/main/java/nl/glorylabs/entity/ArticleCategory.java

package nl.glorylabs.entity;

public enum ArticleCategory {
    DETACHERING_INFO("Over Detachering & Interim"),
    BUREAU_COMPARISON("InterimPlaza vs Andere Bureaus"),
    CV_TIPS("CV & Sollicitatietips"),
    MARKET_INFO("Marktinformatie"),
    FAQ("Veelgestelde Vragen"),
    SUCCESS_STORIES("Succesverhalen");

    private final String displayName;

    ArticleCategory(String displayName) {
        this.displayName = displayName;
    }

    public String getDisplayName() {
        return displayName;
    }
}

File: backend/src/main/java/nl/glorylabs/entity/ArticleRating.java

package nl.glorylabs.entity;

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "article_ratings")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ArticleRating {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "article_id", nullable = false)
    private Article article;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user; // Optional - can be anonymous

    @Column(nullable = false)
    private int rating; // 1-5 stars

    @Column(nullable = false)
    private boolean helpful; // thumbs up/down

    @Column(length = 500)
    private String feedback; // Optional comment

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }
}

File: backend/src/main/java/nl/glorylabs/entity/ArticleBookmark.java

package nl.glorylabs.entity;

import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "article_bookmarks")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ArticleBookmark {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "article_id", nullable = false)
    private Article article;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
    }
}

Afternoon (4 hours): Repositories & Migrations

File: backend/src/main/java/nl/glorylabs/repository/ArticleRepository.java

package nl.glorylabs.repository;

import nl.glorylabs.entity.Article;
import nl.glorylabs.entity.ArticleCategory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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;

import java.util.List;
import java.util.Optional;

@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {

    Optional<Article> findBySlug(String slug);

    Page<Article> findByPublishedTrue(Pageable pageable);

    Page<Article> findByCategoryAndPublishedTrue(ArticleCategory category, Pageable pageable);

    @Query("SELECT a FROM Article a WHERE a.published = true AND " +
           "(LOWER(a.title) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
           "LOWER(a.content) LIKE LOWER(CONCAT('%', :query, '%')) OR " +
           "LOWER(a.summary) LIKE LOWER(CONCAT('%', :query, '%')))")
    Page<Article> searchPublished(@Param("query") String query, Pageable pageable);

    @Query("SELECT a FROM Article a WHERE :tag MEMBER OF a.tags AND a.published = true")
    Page<Article> findByTagAndPublishedTrue(@Param("tag") String tag, Pageable pageable);

    List<Article> findTop5ByPublishedTrueOrderByViewCountDesc();

    @Query("SELECT a FROM Article a WHERE a.id != :articleId AND a.category = :category " +
           "AND a.published = true ORDER BY a.publishedAt DESC")
    List<Article> findRelatedArticles(@Param("articleId") Long articleId,
                                     @Param("category") ArticleCategory category,
                                     Pageable pageable);
}

Create Database Migration:

./mvnw liquibase:diff

End of Day 1: ✅ All entities, repositories created, migrations ready


Day 2 - Tuesday, Nov 5: Service Layer

All Day (8 hours): Business Logic

File: backend/src/main/java/nl/glorylabs/service/ArticleService.java

package nl.glorylabs.service;

import lombok.RequiredArgsConstructor;
import nl.glorylabs.entity.Article;
import nl.glorylabs.entity.ArticleCategory;
import nl.glorylabs.repository.ArticleRepository;
import nl.glorylabs.util.SlugGenerator;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

@Service
@RequiredArgsConstructor
public class ArticleService {

    private final ArticleRepository articleRepository;
    private final SlugGenerator slugGenerator;

    @Transactional(readOnly = true)
    public Page<Article> getAllPublishedArticles(Pageable pageable) {
        return articleRepository.findByPublishedTrue(pageable);
    }

    @Transactional(readOnly = true)
    public Article getArticleBySlug(String slug) {
        return articleRepository.findBySlug(slug)
                .orElseThrow(() -> new RuntimeException("Article not found: " + slug));
    }

    @Transactional
    public void incrementViewCount(Long articleId) {
        Article article = articleRepository.findById(articleId)
                .orElseThrow(() -> new RuntimeException("Article not found"));
        article.setViewCount(article.getViewCount() + 1);
        articleRepository.save(article);
    }

    @Transactional(readOnly = true)
    public Page<Article> getArticlesByCategory(ArticleCategory category, Pageable pageable) {
        return articleRepository.findByCategoryAndPublishedTrue(category, pageable);
    }

    @Transactional(readOnly = true)
    public Page<Article> searchArticles(String query, Pageable pageable) {
        return articleRepository.searchPublished(query, pageable);
    }

    @Transactional(readOnly = true)
    public List<Article> getRelatedArticles(Long articleId, ArticleCategory category) {
        return articleRepository.findRelatedArticles(articleId, category,
                                                     Pageable.ofSize(3));
    }

    @Transactional
    public Article createArticle(Article article) {
        article.setSlug(slugGenerator.generate(article.getTitle()));
        article.setReadingTime(calculateReadingTime(article.getContent()));
        return articleRepository.save(article);
    }

    @Transactional
    public Article updateArticle(Long id, Article updates) {
        Article article = articleRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Article not found"));

        article.setTitle(updates.getTitle());
        article.setSummary(updates.getSummary());
        article.setContent(updates.getContent());
        article.setCategory(updates.getCategory());
        article.setTags(updates.getTags());
        article.setMetaDescription(updates.getMetaDescription());
        article.setFeaturedImage(updates.getFeaturedImage());
        article.setReadingTime(calculateReadingTime(updates.getContent()));

        if (!article.getTitle().equals(updates.getTitle())) {
            article.setSlug(slugGenerator.generate(updates.getTitle()));
        }

        return articleRepository.save(article);
    }

    @Transactional
    public void publishArticle(Long id) {
        Article article = articleRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Article not found"));
        article.setPublished(true);
        article.setPublishedAt(LocalDateTime.now());
        articleRepository.save(article);
    }

    @Transactional
    public void unpublishArticle(Long id) {
        Article article = articleRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Article not found"));
        article.setPublished(false);
        articleRepository.save(article);
    }

    @Transactional
    public void deleteArticle(Long id) {
        articleRepository.deleteById(id);
    }

    private int calculateReadingTime(String content) {
        // Average reading speed: 200 words per minute
        int wordCount = content.split("\\s+").length;
        return Math.max(1, wordCount / 200);
    }
}

File: backend/src/main/java/nl/glorylabs/util/SlugGenerator.java

package nl.glorylabs.util;

import org.springframework.stereotype.Component;

import java.text.Normalizer;
import java.util.Locale;

@Component
public class SlugGenerator {

    public String generate(String input) {
        String normalized = Normalizer.normalize(input, Normalizer.Form.NFD);
        String slug = normalized.replaceAll("[^\\p{ASCII}]", "")
                                .toLowerCase(Locale.ROOT)
                                .replaceAll("[^a-z0-9\\s-]", "")
                                .trim()
                                .replaceAll("\\s+", "-")
                                .replaceAll("-+", "-");
        return slug;
    }
}

End of Day 2: ✅ Service layer complete with business logic


Day 3 - Wednesday, Nov 6: REST API Controllers

Morning (4 hours): Public Endpoints

File: backend/src/main/java/nl/glorylabs/controller/ArticleController.java

package nl.glorylabs.controller;

import lombok.RequiredArgsConstructor;
import nl.glorylabs.dto.ArticleDTO;
import nl.glorylabs.dto.CategoryStatsDTO;
import nl.glorylabs.entity.Article;
import nl.glorylabs.entity.ArticleCategory;
import nl.glorylabs.mapper.ArticleMapper;
import nl.glorylabs.service.ArticleService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api/kb")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class ArticleController {

    private final ArticleService articleService;
    private final ArticleMapper articleMapper;

    @GetMapping("/articles")
    public ResponseEntity<Page<ArticleDTO>> getAllArticles(Pageable pageable) {
        Page<Article> articles = articleService.getAllPublishedArticles(pageable);
        return ResponseEntity.ok(articles.map(articleMapper::toDTO));
    }

    @GetMapping("/articles/{slug}")
    public ResponseEntity<ArticleDTO> getArticle(@PathVariable String slug) {
        Article article = articleService.getArticleBySlug(slug);
        return ResponseEntity.ok(articleMapper.toDetailDTO(article));
    }

    @GetMapping("/articles/category/{category}")
    public ResponseEntity<Page<ArticleDTO>> getArticlesByCategory(
            @PathVariable ArticleCategory category,
            Pageable pageable) {
        Page<Article> articles = articleService.getArticlesByCategory(category, pageable);
        return ResponseEntity.ok(articles.map(articleMapper::toDTO));
    }

    @GetMapping("/articles/search")
    public ResponseEntity<Page<ArticleDTO>> searchArticles(
            @RequestParam String q,
            Pageable pageable) {
        Page<Article> articles = articleService.searchArticles(q, pageable);
        return ResponseEntity.ok(articles.map(articleMapper::toDTO));
    }

    @GetMapping("/articles/{id}/related")
    public ResponseEntity<List<ArticleDTO>> getRelatedArticles(
            @PathVariable Long id,
            @RequestParam ArticleCategory category) {
        List<Article> articles = articleService.getRelatedArticles(id, category);
        return ResponseEntity.ok(articles.stream()
                .map(articleMapper::toDTO)
                .collect(Collectors.toList()));
    }

    @PostMapping("/articles/{id}/view")
    public ResponseEntity<Void> incrementViewCount(@PathVariable Long id) {
        articleService.incrementViewCount(id);
        return ResponseEntity.ok().build();
    }

    @GetMapping("/categories")
    public ResponseEntity<Map<ArticleCategory, Long>> getCategoryStats() {
        // Implementation: count articles per category
        return ResponseEntity.ok(Map.of()); // TODO: implement
    }
}

Afternoon (4 hours): Admin Endpoints

File: backend/src/main/java/nl/glorylabs/controller/AdminArticleController.java

package nl.glorylabs.controller;

import lombok.RequiredArgsConstructor;
import nl.glorylabs.dto.ArticleCreateDTO;
import nl.glorylabs.dto.ArticleDTO;
import nl.glorylabs.dto.ArticleUpdateDTO;
import nl.glorylabs.entity.Article;
import nl.glorylabs.mapper.ArticleMapper;
import nl.glorylabs.service.ArticleService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import jakarta.validation.Valid;

@RestController
@RequestMapping("/api/admin/kb")
@RequiredArgsConstructor
@PreAuthorize("hasRole('ADMIN')")
@CrossOrigin(origins = "*")
public class AdminArticleController {

    private final ArticleService articleService;
    private final ArticleMapper articleMapper;

    @GetMapping("/articles")
    public ResponseEntity<Page<ArticleDTO>> getAllArticles(Pageable pageable) {
        // TODO: Return all articles including drafts
        return ResponseEntity.ok(Page.empty());
    }

    @PostMapping("/articles")
    public ResponseEntity<ArticleDTO> createArticle(
            @Valid @RequestBody ArticleCreateDTO dto) {
        Article article = articleMapper.toEntity(dto);
        Article created = articleService.createArticle(article);
        return ResponseEntity.status(HttpStatus.CREATED)
                .body(articleMapper.toDTO(created));
    }

    @PutMapping("/articles/{id}")
    public ResponseEntity<ArticleDTO> updateArticle(
            @PathVariable Long id,
            @Valid @RequestBody ArticleUpdateDTO dto) {
        Article updates = articleMapper.toEntity(dto);
        Article updated = articleService.updateArticle(id, updates);
        return ResponseEntity.ok(articleMapper.toDTO(updated));
    }

    @PostMapping("/articles/{id}/publish")
    public ResponseEntity<Void> publishArticle(@PathVariable Long id) {
        articleService.publishArticle(id);
        return ResponseEntity.ok().build();
    }

    @PostMapping("/articles/{id}/unpublish")
    public ResponseEntity<Void> unpublishArticle(@PathVariable Long id) {
        articleService.unpublishArticle(id);
        return ResponseEntity.ok().build();
    }

    @DeleteMapping("/articles/{id}")
    public ResponseEntity<Void> deleteArticle(@PathVariable Long id) {
        articleService.deleteArticle(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/analytics")
    public ResponseEntity<Map<String, Object>> getAnalytics() {
        // TODO: Return analytics (views, ratings, popular articles)
        return ResponseEntity.ok(Map.of());
    }
}

End of Day 3: ✅ REST API complete, ready for frontend


[Continued in next file due to length...]

Reacties

Nog geen reacties