Move backend files into a sub folder.

This commit is contained in:
Florian THIERRY
2024-03-27 10:28:22 +01:00
parent 39663e914d
commit 431d365d20
131 changed files with 3633 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.codiki</groupId>
<artifactId>codiki-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<artifactId>codiki-infrastructure</artifactId>
<name>codiki-infrastructure</name>
<description>Demo project for Spring Boot</description>
<packaging>jar</packaging>
<dependencies>
<dependency>
<groupId>org.codiki</groupId>
<artifactId>codiki-domain</artifactId>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,72 @@
package org.codiki.infrastructure.category;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.category.model.Category;
import org.codiki.domain.category.port.CategoryPort;
import org.codiki.infrastructure.category.model.CategoryEntity;
import org.codiki.infrastructure.category.repository.CategoryRepository;
import org.springframework.stereotype.Component;
@Component
public class CategoryJpaAdapter implements CategoryPort {
private final CategoryRepository categoryRepository;
public CategoryJpaAdapter(CategoryRepository categoryRepository) {
this.categoryRepository = categoryRepository;
}
@Override
public Optional<Category> findById(UUID categoryId) {
return categoryRepository.findById(categoryId)
.map(CategoryEntity::toDomain);
}
@Override
public void save(Category category) {
CategoryEntity categoryEntity = new CategoryEntity(category);
categoryRepository.save(categoryEntity);
}
@Override
public List<Category> findAllByIds(List<UUID> categoryIds) {
final List<Category> categories = categoryRepository.findAllById(categoryIds)
.stream()
.map(CategoryEntity::toDomain)
.toList();
Optional<UUID> notFoundCategoryId = categoryIds.stream()
.filter(categoryId -> categories.stream().map(Category::id).noneMatch(categoryId::equals))
.findFirst();
if (notFoundCategoryId.isPresent()) {
throw new CategoryNotFoundException(notFoundCategoryId.get());
}
return categories;
}
@Override
public boolean existsAnyAssociatedPublication(UUID categoryId) {
return categoryRepository.existsAnyAssociatedPublication(categoryId);
}
@Override
public void deleteById(UUID categoryId) {
categoryRepository.deleteById(categoryId);
}
@Override
public boolean existsById(UUID categoryId) {
return categoryRepository.existsById(categoryId);
}
@Override
public List<Category> findAll() {
return categoryRepository.findAll()
.stream()
.map(CategoryEntity::toDomain)
.toList();
}
}

View File

@@ -0,0 +1,63 @@
package org.codiki.infrastructure.category.model;
import static java.util.Collections.emptyList;
import static java.util.stream.Collectors.toSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import org.codiki.domain.category.model.Category;
import static jakarta.persistence.CascadeType.ALL;
import static jakarta.persistence.FetchType.LAZY;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "category")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class CategoryEntity {
@Id
private UUID id;
@Column(nullable = false)
private String name;
@OneToMany
@JoinColumn(name = "parent_category_id")
private Set<CategoryEntity> subCategories;
public CategoryEntity(Category category) {
this(
category.id(),
category.name(),
category.subCategories()
.stream()
.map(CategoryEntity::new)
.collect(toSet())
);
}
public Category toDomain() {
return new Category(
id,
name,
subCategories.stream()
.map(CategoryEntity::toDomain)
.toList()
);
}
}

View File

@@ -0,0 +1,19 @@
package org.codiki.infrastructure.category.repository;
import java.util.UUID;
import org.codiki.infrastructure.category.model.CategoryEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface CategoryRepository extends JpaRepository<CategoryEntity, UUID> {
@Query(value = """
SELECT (
SELECT COUNT(*)
FROM publication p
WHERE p.category_id = :categoryId
) > 0
""", nativeQuery = true)
boolean existsAnyAssociatedPublication(@Param("categoryId") UUID categoryId);
}

View File

@@ -0,0 +1,11 @@
package org.codiki.infrastructure.configuration;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@Configuration
@EnableJpaRepositories("org.codiki.infrastructure")
@EntityScan("org.codiki.infrastructure")
public class JpaConfiguration {
}

View File

@@ -0,0 +1,69 @@
package org.codiki.infrastructure.picture;
import java.io.File;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.picture.model.builder.PictureBuilder.aPicture;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.exception.PictureStorageErrorException;
import org.codiki.domain.picture.model.Picture;
import org.codiki.domain.picture.port.PicturePort;
import org.codiki.infrastructure.picture.model.PictureEntity;
import org.codiki.infrastructure.picture.repository.PictureRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import jakarta.transaction.Transactional;
@Component
public class PictureJpaAdapter implements PicturePort {
private final PictureRepository repository;
private final String pictureFolderPath;
public PictureJpaAdapter(
PictureRepository repository,
@Value("${application.pictures.path}") String pictureFolderPath
) {
this.repository = repository;
this.pictureFolderPath = pictureFolderPath;
}
@Override
public boolean existsById(UUID pictureId) {
return repository.existsById(pictureId);
}
@Override
public Optional<Picture> findById(UUID pictureId) {
return repository.findById(pictureId)
.map(PictureEntity::toDomain)
.map(picture -> {
File pictureFile = new File(String.format("%s/%s", pictureFolderPath, pictureId));
if (!pictureFile.exists()) {
throw new PictureNotFoundException(pictureId);
}
return aPicture()
.basedOn(picture)
.withContentFile(pictureFile)
.build();
});
}
@Override
@Transactional
public void save(Picture picture) {
PictureEntity pictureEntity = new PictureEntity(picture);
repository.save(pictureEntity);
boolean isMoved = picture.contentFile().renameTo(new File(String.format("%s/%s", pictureFolderPath, picture.id())));
if (!isMoved) {
throw new PictureStorageErrorException();
}
}
@Override
public void deleteById(UUID pictureId) {
repository.deleteById(pictureId);
}
}

View File

@@ -0,0 +1,36 @@
package org.codiki.infrastructure.picture.model;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "picture")
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class PictureEntity {
@Id
private UUID id;
@Column(nullable = false)
private UUID publisherId;
public PictureEntity(Picture picture) {
id = picture.id();
publisherId = picture.publisherId();
}
public Picture toDomain() {
return new Picture(id, publisherId, null);
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.infrastructure.picture.repository;
import java.util.UUID;
import org.codiki.infrastructure.picture.model.PictureEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PictureRepository extends JpaRepository<PictureEntity, UUID> {
}

View File

@@ -0,0 +1,58 @@
package org.codiki.infrastructure.publication;
import static java.util.Collections.reverseOrder;
import static java.util.Comparator.comparingInt;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.port.PublicationPort;
import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.codiki.infrastructure.publication.model.PublicationSearchResult;
import org.codiki.infrastructure.publication.repository.PublicationRepository;
import org.springframework.stereotype.Component;
@Component
public class PublicationJpaAdapter implements PublicationPort {
private final PublicationRepository repository;
private final PublicationSearchCriteriaJpaAdapter publicationSearchCriteriaJpaAdapter;
public PublicationJpaAdapter(
PublicationRepository repository,
PublicationSearchCriteriaJpaAdapter publicationSearchCriteriaJpaAdapter
) {
this.repository = repository;
this.publicationSearchCriteriaJpaAdapter = publicationSearchCriteriaJpaAdapter;
}
@Override
public void save(Publication publication) {
PublicationEntity newPublicationEntity = new PublicationEntity(publication);
repository.save(newPublicationEntity);
}
@Override
public Optional<Publication> findById(UUID publicationId) {
return repository.findById(publicationId)
.map(PublicationEntity::toDomain);
}
@Override
public void delete(Publication publication) {
repository.deleteById(publication.id());
}
@Override
public List<Publication> search(List<PublicationSearchCriterion> criteria) {
List<PublicationSearchCriterion> adaptedCriteria = publicationSearchCriteriaJpaAdapter.adaptCriteriaForJpa(criteria);
return repository.search(adaptedCriteria)
.stream()
.map(PublicationEntity::toDomain)
.map(publication -> new PublicationSearchResult(publication, criteria))
.sorted(reverseOrder(comparingInt(PublicationSearchResult::getSearchScore)))
.map(PublicationSearchResult::getPublication)
.toList();
}
}

View File

@@ -0,0 +1,34 @@
package org.codiki.infrastructure.publication;
import java.util.LinkedList;
import java.util.List;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.springframework.stereotype.Component;
@Component
public class PublicationSearchCriteriaJpaAdapter {
public List<PublicationSearchCriterion> adaptCriteriaForJpa(List<PublicationSearchCriterion> initialCriteria) {
List<PublicationSearchCriterion> result = new LinkedList<>();
for (PublicationSearchCriterion criterion : initialCriteria) {
boolean criterionAdaptationOccurred = false;
if (criterion.value() instanceof String criterionValue) {
String unaccentedCriterionValue = criterionValue.replaceAll("[àáâãäåçèéêëìíîïñòóôõöùúûüýÿ]", "_");
result.add(new PublicationSearchCriterion(
criterion.searchField(),
criterion.searchType(),
unaccentedCriterionValue.toLowerCase()
));
criterionAdaptationOccurred = true;
}
if (!criterionAdaptationOccurred) {
result.add(criterion);
}
}
return result;
}
}

View File

@@ -0,0 +1,40 @@
package org.codiki.infrastructure.publication.model;
import java.util.UUID;
import org.codiki.domain.publication.model.Author;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "`user`")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AuthorEntity {
@Id
private UUID id;
@Column(nullable = false)
private String pseudo;
// private String illustrationId;
public AuthorEntity(Author author) {
this(
author.id(),
author.name()
// author.illustrationId()
);
}
public Author toDomain() {
return new Author(id, pseudo, "image");
}
}

View File

@@ -0,0 +1,75 @@
package org.codiki.infrastructure.publication.model;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.publication.model.Publication;
import org.codiki.infrastructure.category.model.CategoryEntity;
import static jakarta.persistence.FetchType.LAZY;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "publication")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PublicationEntity {
@Id
private UUID id;
@Column(nullable = false)
private String key;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String text;
@Column(nullable = false)
private String description;
@Column(nullable = false)
private ZonedDateTime creationDate;
@Column(nullable = false)
private UUID illustrationId;
@Column(nullable = false)
private UUID categoryId;
@ManyToOne(fetch = LAZY)
@JoinColumn(name = "author_id")
private AuthorEntity author;
public PublicationEntity(Publication publication) {
this(
publication.id(),
publication.key(),
publication.title(),
publication.text(),
publication.description(),
publication.creationDate(),
publication.illustrationId(),
publication.categoryId(),
new AuthorEntity(publication.author())
);
}
public Publication toDomain() {
return new Publication(
id,
key,
title,
text,
description,
creationDate,
illustrationId,
categoryId,
author.toDomain()
);
}
}

View File

@@ -0,0 +1,117 @@
package org.codiki.infrastructure.publication.model;
import java.util.List;
import java.util.Optional;
import java.util.stream.Stream;
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.DESCRIPTION;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TEXT;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TITLE;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.model.search.PublicationSearchField;
import lombok.Getter;
@Getter
public class PublicationSearchResult {
private final Publication publication;
private final int searchScore;
public PublicationSearchResult(
Publication publication,
List<PublicationSearchCriterion> criteria
) {
this.publication = publication;
int score = 0;
score += computeCriterionScoreForField(TITLE, publication, criteria, 1000, 10, 3);
score += computeCriterionScoreForField(DESCRIPTION, publication, criteria, 100, 7, 2);
score += computeCriterionScoreForField(TEXT, publication, criteria, 100, 4, 1);
searchScore = score;
}
private static int computeCriterionScoreForField(
PublicationSearchField field,
Publication publication,
List<PublicationSearchCriterion> criteria,
int bountyForPerfectMatch,
int bountyForWordMatch,
int bountyForWordContaingMatch
) {
return getLowercaseFieldValue(field, publication)
.map(fieldValue ->
criteria.stream()
.filter(criterion -> criterion.searchType() == CONTAINS)
.filter(criterion -> criterion.searchField() == field)
.map(PublicationSearchCriterion::value)
.filter(String.class::isInstance)
.map(String.class::cast)
.map(String::toLowerCase)
.map(criterionValue ->
computeCriterionScore(
criterionValue,
fieldValue,
bountyForPerfectMatch,
bountyForWordMatch,
bountyForWordContaingMatch
)
)
.mapToInt(Integer::intValue)
.sum()
)
.orElse(0);
}
private static Optional<String> getLowercaseFieldValue(PublicationSearchField field, Publication publication) {
return Optional.ofNullable(
switch (field) {
case TITLE -> publication.title();
case DESCRIPTION -> publication.description();
case TEXT -> publication.text();
default -> null;
}
).map(String::toLowerCase);
}
private static int computeCriterionScore(
String criterionValue,
String publicationTitle,
int bountyForPerfectMatch,
int bountyForWordMatch,
int bountyForWordContaingMatch
) {
int result = 0;
if (publicationTitle.equals(criterionValue)) {
result = bountyForPerfectMatch;
} else if (publicationTitle.contains(criterionValue)) {
result = Stream.of(publicationTitle.split(" "))
.map(titleWord -> computeWordScore(criterionValue, titleWord, bountyForWordMatch, bountyForWordContaingMatch))
.mapToInt(Integer::intValue)
.sum();
}
return result;
}
private static int computeWordScore(
String criterionValue,
String titleWord,
int bountyForWordMatch,
int bountyForWordContaingMatch
) {
int result = 0;
if (titleWord.equals(criterionValue)) {
result = bountyForWordMatch;
} else if (titleWord.contains(criterionValue)) {
result = bountyForWordContaingMatch;
}
return result;
}
}

View File

@@ -0,0 +1,10 @@
package org.codiki.infrastructure.publication.repository;
import java.util.List;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.infrastructure.publication.model.PublicationEntity;
public interface CustomPublicationRepository {
List<PublicationEntity> search(List<PublicationSearchCriterion> criteria);
}

View File

@@ -0,0 +1,41 @@
package org.codiki.infrastructure.publication.repository;
import java.util.List;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.springframework.stereotype.Repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
@Repository
public class CustomPublicationRepositoryImpl implements CustomPublicationRepository {
private final EntityManager entityManager;
private final PublicationPredicateMapper publicationPredicateMapper;
public CustomPublicationRepositoryImpl(EntityManager entityManager, PublicationPredicateMapper publicationPredicateMapper) {
this.entityManager = entityManager;
this.publicationPredicateMapper = publicationPredicateMapper;
}
@Override
public List<PublicationEntity> search(final List<PublicationSearchCriterion> criteria) {
CriteriaBuilder criteriaBuilder = entityManager.getCriteriaBuilder();
CriteriaQuery<PublicationEntity> query = criteriaBuilder.createQuery(PublicationEntity.class);
Root<PublicationEntity> fromPublication = query.from(PublicationEntity.class);
Predicate predicate = publicationPredicateMapper.map(criteria, criteriaBuilder, fromPublication);
CriteriaQuery<PublicationEntity> criteriaQuery = query.select(fromPublication)
.distinct(true)
.where(predicate);
return entityManager.createQuery(criteriaQuery)
.getResultList();
}
}

View File

@@ -0,0 +1,83 @@
package org.codiki.infrastructure.publication.repository;
import java.util.List;
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_PSEUDO;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.model.search.PublicationSearchField;
import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.springframework.stereotype.Component;
import static jakarta.persistence.criteria.JoinType.LEFT;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.From;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
@Component
public class PublicationPredicateMapper {
public Predicate map(
List<PublicationSearchCriterion> criteria,
CriteriaBuilder criteriaBuilder,
Root<PublicationEntity> fromPublication
) {
List<Predicate> criteriaPredicates = criteria.stream()
.map(criterion -> map(criterion, criteriaBuilder, fromPublication))
.toList();
return criteriaBuilder.or(criteriaPredicates.toArray(new Predicate[]{}));
}
private Predicate map(
PublicationSearchCriterion criterion,
CriteriaBuilder criteriaBuilder,
Root<PublicationEntity> fromPublication
) {
return switch (criterion.searchType()) {
case EQUALS -> mapEqualsPredicate(criteriaBuilder, fromPublication, criterion.searchField(), criterion.value());
case CONTAINS -> mapContainsPredicate(criteriaBuilder, fromPublication, criterion.searchField(), criterion.value());
default -> null;
};
}
private Predicate mapEqualsPredicate(
CriteriaBuilder criteriaBuilder,
Root<PublicationEntity> fromPublication,
PublicationSearchField searchField,
Object value
) {
From<?, ?> from = fromPublication;
String attributeName = searchField.name().toLowerCase();
if (searchField == AUTHOR_PSEUDO) {
from = fromPublication.join("author", LEFT);
attributeName = "pseudo";
}
return criteriaBuilder.equal(
criteriaBuilder.lower(
from.get(attributeName)
),
value
);
}
private Predicate mapContainsPredicate(
CriteriaBuilder criteriaBuilder,
Root<PublicationEntity> fromPublication,
PublicationSearchField searchField,
Object value
) {
From<?, ?> from = fromPublication;
String attributeName = searchField.name().toLowerCase();
if (searchField == AUTHOR_PSEUDO) {
from = fromPublication.join("author", LEFT);
attributeName = "pseudo";
}
return criteriaBuilder.like(
criteriaBuilder.lower(
from.get(attributeName)
),
String.format("%%%s%%", value)
);
}
}

View File

@@ -0,0 +1,19 @@
package org.codiki.infrastructure.publication.repository;
import java.util.Optional;
import java.util.UUID;
import org.codiki.infrastructure.publication.model.PublicationEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface PublicationRepository extends JpaRepository<PublicationEntity, UUID>, CustomPublicationRepository {
@Query("""
SELECT p
FROM PublicationEntity p
JOIN FETCH p.author a
WHERE p.id = :publicationId
""")
Optional<PublicationEntity> findById(@Param("publicationId") UUID publicationId);
}

View File

@@ -0,0 +1,82 @@
package org.codiki.infrastructure.user.adapter;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.user.model.RefreshToken;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.port.UserPort;
import org.codiki.infrastructure.user.model.RefreshTokenEntity;
import org.codiki.infrastructure.user.repository.RefreshTokenJpaRepository;
import org.codiki.infrastructure.user.repository.UserJpaRepository;
import org.codiki.infrastructure.user.model.UserEntity;
import org.springframework.stereotype.Component;
@Component
public class UserJpaAdapter implements UserPort {
private final RefreshTokenJpaRepository refreshTokenJpaRepository;
private final UserJpaRepository userJpaRepository;
public UserJpaAdapter(
RefreshTokenJpaRepository refreshTokenJpaRepository,
UserJpaRepository userJpaRepository
) {
this.refreshTokenJpaRepository = refreshTokenJpaRepository;
this.userJpaRepository = userJpaRepository;
}
@Override
public Optional<User> findById(UUID userId) {
return userJpaRepository.findById(userId)
.map(UserEntity::toUser);
}
@Override
public Optional<User> findByEmail(String userEmail) {
return userJpaRepository.findByEmail(userEmail)
.map(UserEntity::toUser);
}
@Override
public List<User> findAll() {
return userJpaRepository.findAll()
.stream()
.map(UserEntity::toUser)
.toList();
}
@Override
public void save(User user) {
UserEntity userEntity = new UserEntity(user);
userJpaRepository.save(userEntity);
}
@Override
public boolean existsById(UUID userId) {
return userJpaRepository.existsById(userId);
}
@Override
public boolean existsByEmail(String email) {
return userJpaRepository.existsByEmail(email);
}
@Override
public Optional<RefreshToken> findRefreshTokenByUserId(UUID userId) {
return refreshTokenJpaRepository.findByUserId(userId)
.map(RefreshTokenEntity::toRefreshToken);
}
@Override
public Optional<RefreshToken> findRefreshTokenById(UUID refreshTokenId) {
return refreshTokenJpaRepository.findByValue(refreshTokenId)
.map(RefreshTokenEntity::toRefreshToken);
}
@Override
public void save(RefreshToken refreshToken) {
RefreshTokenEntity refreshTokenEntity = new RefreshTokenEntity(refreshToken);
refreshTokenJpaRepository.save(refreshTokenEntity);
}
}

View File

@@ -0,0 +1,40 @@
package org.codiki.infrastructure.user.model;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.user.model.RefreshToken;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "refresh_token")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class RefreshTokenEntity {
@Id
private UUID userId;
@Column(nullable = false)
private UUID value;
@Column(nullable = false)
private ZonedDateTime expirationDate;
public RefreshTokenEntity(RefreshToken refreshToken) {
userId = refreshToken.userId();
value = refreshToken.value();
expirationDate = refreshToken.expirationDate();
}
public RefreshToken toRefreshToken() {
return new RefreshToken(userId, value, expirationDate);
}
}

View File

@@ -0,0 +1,65 @@
package org.codiki.infrastructure.user.model;
import java.util.List;
import java.util.UUID;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserRole;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "`user`")
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class UserEntity {
@Id
private UUID id;
@Column(nullable = false)
private String pseudo;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private String password;
@Column
private UUID photoId;
@ElementCollection(targetClass = UserRole.class)
@CollectionTable(
name = "user_role",
joinColumns = @JoinColumn(name = "user_id")
)
@Column(name = "role")
private List<UserRole> roles;
public UserEntity(User user) {
id = user.id();
pseudo = user.pseudo();
email = user.email();
password = user.password();
photoId = user.photoId();
roles = user.roles();
}
public User toUser() {
return new User(
id,
pseudo,
email,
password,
photoId,
roles
);
}
}

View File

@@ -0,0 +1,15 @@
package org.codiki.infrastructure.user.repository;
import java.util.Optional;
import java.util.UUID;
import org.codiki.infrastructure.user.model.RefreshTokenEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface RefreshTokenJpaRepository extends JpaRepository<RefreshTokenEntity, UUID> {
Optional<RefreshTokenEntity> findByUserId(UUID userId);
Optional<RefreshTokenEntity> findByValue(UUID refreshTokenId);
}

View File

@@ -0,0 +1,25 @@
package org.codiki.infrastructure.user.repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.infrastructure.user.model.UserEntity;
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;
@Repository
public interface UserJpaRepository extends JpaRepository<UserEntity, UUID> {
@Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.id = :userId")
Optional<UserEntity> findById(@Param("userId") UUID userId);
@Query("SELECT u FROM UserEntity u JOIN FETCH u.roles WHERE u.email = :email")
Optional<UserEntity> findByEmail(@Param("email") String userEmail);
@Query("SELECT u FROM UserEntity u JOIN FETCH u.roles")
List<UserEntity> findAll();
boolean existsByEmail(String email);
}

View File

@@ -0,0 +1,12 @@
insert into "user" values
('5ad462b8-8f9e-4a26-bb86-c74fef5d11b6', 'Standard user', 'standard.user@codiki.org', '$2a$10$FVhrYRXw.Zw2V5jGUkvX/.1U.IdWlwd8J.Y/5pb5etAzyoBhJ3FHG', null),
('15a13dc7-029d-4eab-a63d-c1e96f90241d', 'Admin user', 'admin.user@codiki.org', '$2a$10$FVhrYRXw.Zw2V5jGUkvX/.1U.IdWlwd8J.Y/5pb5etAzyoBhJ3FHG', null);
insert into user_role values
('5ad462b8-8f9e-4a26-bb86-c74fef5d11b6', 0),
('15a13dc7-029d-4eab-a63d-c1e96f90241d', 0),
('15a13dc7-029d-4eab-a63d-c1e96f90241d', 1);
insert into category values
('172fa901-3f4b-4540-92f3-1c15820e8ec9', 'Main category', null),
('3f4b4540-a901-92f3-1c15-8ec9172f820e', 'Sub category', '172fa901-3f4b-4540-92f3-1c15820e8ec9');

View File

@@ -0,0 +1,12 @@
\c codiki_db
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE USER codiki_user
WITH PASSWORD 'password'
NOCREATEDB;
GRANT SELECT, INSERT, UPDATE, DELETE
ON ALL TABLES
IN SCHEMA public
TO codiki_user;

View File

@@ -0,0 +1,65 @@
CREATE TABLE IF NOT EXISTS "user" (
id UUID NOT NULL,
pseudo VARCHAR NOT NULL,
email VARCHAR NOT NULL,
password VARCHAR NOT NULL,
photo_id UUID,
CONSTRAINT user_pk PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS user_role (
user_id UUID NOT NULL,
role SMALLINT,
CONSTRAINT user_role_pk PRIMARY KEY (user_id, role),
CONSTRAINT user_role_fk_user_id FOREIGN KEY (user_id) REFERENCES "user" (id)
);
CREATE INDEX user_role_fk_user_id_idx ON user_role (user_id);
CREATE TABLE IF NOT EXISTS refresh_token (
user_id UUID NOT NULL,
value UUID NOT NULL,
expiration_date TIMESTAMP NOT NULL,
CONSTRAINT refresh_token_pk PRIMARY KEY (user_id),
CONSTRAINT refresh_token_fk_user_id FOREIGN KEY (user_id) REFERENCES "user" (id)
);
CREATE INDEX refresh_token_fk_user_id_idx ON user_role (user_id);
CREATE TABLE IF NOT EXISTS category (
id UUID NOT NULL,
name VARCHAR NOT NULL,
parent_category_id UUID,
CONSTRAINT category_pk PRIMARY KEY (id),
CONSTRAINT category_parent_category_id_fk FOREIGN KEY (parent_category_id) REFERENCES category (id)
);
CREATE INDEX category_parent_category_id_idx ON category (parent_category_id);
CREATE TABLE IF NOT EXISTS picture (
id UUID NOT NULL,
publisher_id UUID NOT NULL,
CONSTRAINT picture_pk PRIMARY KEY (id),
CONSTRAINT picture_publisher_id_fk FOREIGN KEY (publisher_id) REFERENCES "user" (id)
);
CREATE INDEX picture_publisher_id_idx ON picture (publisher_id);
ALTER TABLE "user" ADD CONSTRAINT user_photo_id_fk FOREIGN KEY (photo_id) REFERENCES picture (id);
CREATE INDEX user_photo_id_idx ON "user" (photo_id);
CREATE TABLE IF NOT EXISTS publication (
id UUID NOT NULL,
key VARCHAR(14) NOT NULL,
title VARCHAR NOT NULL,
text VARCHAR NOT NULL,
description VARCHAR NOT NULL,
creation_date TIMESTAMP NOT NULL,
illustration_id UUID NOT NULL,
author_id UUID NOT NULL,
category_id UUID NOT NULL,
CONSTRAINT publication_pk PRIMARY KEY (id),
CONSTRAINT publication_picture_id_fk FOREIGN KEY (illustration_id) REFERENCES picture (id),
CONSTRAINT publication_author_id_fk FOREIGN KEY (author_id) REFERENCES "user" (id),
CONSTRAINT publication_category_id_fk FOREIGN KEY (category_id) REFERENCES category (id)
);
CREATE INDEX publication_picture_id_idx ON publication (illustration_id);
CREATE INDEX publication_author_id_idx ON publication (author_id);
CREATE INDEX publication_category_id_idx ON publication (category_id);

View File

@@ -0,0 +1,40 @@
package org.codiki.infrastructure.publication;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.KEY;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
class PublicationSearchCriteriaJpaAdapterTest {
private PublicationSearchCriteriaJpaAdapter adapter;
@BeforeEach
void setUp() {
adapter = new PublicationSearchCriteriaJpaAdapter();
}
@Nested
public class AdaptCriteriaForJpa {
@Test
void should_adapt_criteria_for_jpa() {
// given
List<PublicationSearchCriterion> initialCriteria = List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "critère")
);
// when
List<PublicationSearchCriterion> result = adapter.adaptCriteriaForJpa(initialCriteria);
// then
List<PublicationSearchCriterion> expectedResult = List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "crit_re")
);
assertThat(result).isEqualTo(expectedResult);
}
}
}

View File

@@ -0,0 +1,140 @@
package org.codiki.infrastructure.publication.model;
import java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication;
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.DESCRIPTION;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TEXT;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TITLE;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.builder.PublicationBuilder;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class PublicationSearchResultTest {
@ParameterizedTest
@MethodSource("arguments_of_constructor_should_compute_score")
void constructor_should_compute_score(
String functionalRule,
String fieldValue,
List<PublicationSearchCriterion> criteria,
int expectedPoints
) {
// given
PublicationBuilder publicationBuilder = aPublication();
switch (criteria.getFirst().searchField()) {
case TITLE -> publicationBuilder.withTitle(fieldValue);
case DESCRIPTION -> publicationBuilder.withDescription(fieldValue);
case TEXT -> publicationBuilder.withText(fieldValue);
}
Publication publication = publicationBuilder.build();
// when
PublicationSearchResult publicationSearchResult = new PublicationSearchResult(publication, criteria);
// then
assertThat(publicationSearchResult.getSearchScore()).isEqualTo(expectedPoints);
}
private static Stream<Arguments> arguments_of_constructor_should_compute_score() {
return Stream.of(
// TITLE RULES
Arguments.of(
"exact match for title gives 1000 pts",
"Exact title",
List.of(new PublicationSearchCriterion(TITLE, CONTAINS, "Exact title")),
1000
),
Arguments.of(
"one match in title gives 10 pts",
"Exact title",
List.of(new PublicationSearchCriterion(TITLE, CONTAINS, "exact")),
10
),
Arguments.of(
"multiple matches in title gives 10 pts per match",
"One super title with lot of words",
List.of(
new PublicationSearchCriterion(TITLE, CONTAINS, "super"),
new PublicationSearchCriterion(TITLE, CONTAINS, "words")
),
20
),
Arguments.of(
"one match in title gives 1 pts per match if criterion value is a title word part",
"One super title",
List.of(
new PublicationSearchCriterion(TITLE, CONTAINS, "sup")
),
3
),
// DESCRIPTION RULES
Arguments.of(
"exact match for description gives 100 pts",
"Exact description",
List.of(new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "Exact description")),
100
),
Arguments.of(
"one match in description gives 7 pts",
"Exact description",
List.of(new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "exact")),
7
),
Arguments.of(
"multiple matches in description gives 7 pts per match",
"One super description with lot of words",
List.of(
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "super"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "words")
),
14
),
Arguments.of(
"one match in description gives 2 pts per match if criterion value is a description word part",
"One super description",
List.of(
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "sup")
),
2
),
// TEXT RULES
Arguments.of(
"exact match for text gives 100 pts",
"Exact text",
List.of(new PublicationSearchCriterion(TEXT, CONTAINS, "Exact text")),
100
),
Arguments.of(
"one match in text gives 7 pts",
"Exact text",
List.of(new PublicationSearchCriterion(TEXT, CONTAINS, "exact")),
4
),
Arguments.of(
"multiple matches in text gives 7 pts per match",
"One super text with lot of words",
List.of(
new PublicationSearchCriterion(TEXT, CONTAINS, "super"),
new PublicationSearchCriterion(TEXT, CONTAINS, "words")
),
8
),
Arguments.of(
"one match in text gives 2 pts per match if criterion value is a text word part",
"One super text",
List.of(
new PublicationSearchCriterion(TEXT, CONTAINS, "sup")
),
1
)
);
}
}