132 Commits

Author SHA1 Message Date
Florian THIERRY
c4f887bf33 Use signals ont publication creation and update pages. 2024-09-25 09:27:24 +02:00
Florian THIERRY
61ec781bbb Use signals in side menu. 2024-09-25 09:19:07 +02:00
Florian THIERRY
3ee41cf571 Use signals on MyPublications page. 2024-09-25 09:11:22 +02:00
Florian THIERRY
7f2e762a44 Use signals in publication page. 2024-09-25 09:07:13 +02:00
Florian THIERRY
cfa3015a7b Use signals on home page. 2024-09-25 08:59:32 +02:00
Florian THIERRY
778a92fd5e Enable zoneless configuration. 2024-09-25 08:36:06 +02:00
ff52a198dc Upgrade to angular 18. 2024-09-24 22:47:15 +02:00
Florian THIERRY
f3d59a0ef3 conf cleaning. 2024-09-22 13:18:08 +02:00
Florian THIERRY
d84485e52b test 2024-09-22 13:03:39 +02:00
Florian THIERRY
f789d89995 test 2024-09-22 12:53:55 +02:00
Florian THIERRY
7e0174bcc2 test 2024-09-22 12:46:41 +02:00
Florian THIERRY
fe1d59a3bb test 2024-09-22 12:37:01 +02:00
Florian THIERRY
69a99c9312 test 2024-09-22 12:34:02 +02:00
Florian THIERRY
a6414ae64d test 2024-09-22 12:30:18 +02:00
Florian THIERRY
9cf47f0e2a test 2024-09-22 12:27:59 +02:00
Florian THIERRY
6c89562dc3 test 2024-09-22 12:14:04 +02:00
Florian THIERRY
e85eabbed5 test 2024-09-22 12:01:05 +02:00
Florian THIERRY
1ec4ba8212 test conf prod. 2024-09-22 11:10:01 +02:00
Florian THIERRY
a1ff181443 test conf prod. 2024-09-22 11:09:14 +02:00
Florian THIERRY
ee8f48bc43 Fix prod fr configuration. 2024-09-22 10:55:49 +02:00
Florian THIERRY
7ec1aee884 test 2024-09-21 23:39:46 +02:00
Florian THIERRY
a3adfa8ee0 Fix bug of preview content. 2024-09-21 22:20:00 +02:00
Florian THIERRY
d893afa1f3 i18n 2024-09-21 21:43:09 +02:00
Florian THIERRY
d984128176 i18n 2024-09-21 21:40:49 +02:00
Florian THIERRY
f8d73c9ed0 i18n 2024-09-21 21:34:16 +02:00
Florian THIERRY
208b935ffa i18n for some components. 2024-09-21 21:17:31 +02:00
Florian THIERRY
f12dfc7029 i18n for files in core package. 2024-09-21 21:09:36 +02:00
Florian THIERRY
98a890e915 i18n for signin page. 2024-09-21 21:06:47 +02:00
Florian THIERRY
0c1b52d734 i18n for publication search page. 2024-09-21 21:03:34 +02:00
Florian THIERRY
3f6764dd7d i18n for publication update page. 2024-09-21 21:00:59 +02:00
Florian THIERRY
67c3d0b3e6 i18n of publication creation page. 2024-09-21 20:57:05 +02:00
Florian THIERRY
36208ef071 i18n of publication page. 2024-09-21 12:28:55 +02:00
Florian THIERRY
b546a0cf01 Add i18n completion tool. 2024-09-21 12:20:22 +02:00
Florian THIERRY
3935f6ad21 i18n for login page. 2024-09-21 11:22:35 +02:00
Florian THIERRY
fd5ad7e88e i18n for disconnection page. 2024-09-21 11:13:08 +02:00
Florian THIERRY
8c957fe694 i18n for home page. 2024-09-21 11:12:07 +02:00
Florian THIERRY
cb0ef7ddd5 Test 2024-09-20 18:38:17 +02:00
Florian THIERRY
42e466fe8b test ci. 2024-09-20 17:32:43 +02:00
Florian THIERRY
2c5fa4fa13 test ci. 2024-09-20 17:25:37 +02:00
Florian THIERRY
ebce44c889 test ci 2024-09-20 17:12:13 +02:00
Florian THIERRY
955dc48f51 test ci 2024-09-20 16:46:11 +02:00
Florian THIERRY
29e75e6298 Test ci. 2024-09-20 16:34:29 +02:00
Florian THIERRY
187fd105d3 Fix pictures paths. 2024-09-20 09:36:10 +02:00
Florian THIERRY
1b92fd269e Fix config. 2024-09-19 22:24:39 +02:00
Florian THIERRY
23025e3606 Fix fucking MIME types of javascript files. 2024-09-19 22:13:10 +02:00
Florian THIERRY
38c11e2d9f gitignore. 2024-09-19 18:03:33 +02:00
Florian THIERRY
610723c561 CI/CD drafting. 2024-09-19 18:03:23 +02:00
Florian THIERRY
0e2fb945a4 Initialize docker build configuration. 2024-09-18 10:18:40 +02:00
Florian THIERRY
ef32572521 Change favicon. 2024-09-18 09:22:11 +02:00
Florian THIERRY
e5a128a7f6 Fix style. 2024-09-18 09:10:46 +02:00
Florian THIERRY
f5e1e10ebd Add confirmation dialog to delete publications. 2024-09-18 09:05:18 +02:00
Florian THIERRY
500952d4d4 Implementation of categoryId edition. 2024-09-10 10:47:58 +02:00
Florian THIERRY
1cc4abc24e Add default picture and factorization of title in publication edition. 2024-09-10 09:40:45 +02:00
Florian THIERRY
00945de270 Code cleaning about publication edition component. 2024-09-06 10:20:05 +02:00
Florian THIERRY
5804d8cc9f Extract publication edition into a separated component. 2024-09-05 17:28:30 +02:00
Florian THIERRY
5610bd170a Minor changes 2024-09-05 09:35:46 +02:00
Florian THIERRY
21d19d4ecd Styling elements. 2024-09-05 09:33:07 +02:00
Florian THIERRY
f3dfac6bc7 Add delete button. 2024-09-05 09:23:25 +02:00
Florian THIERRY
4565192d0b Remote temp configuration. 2024-09-04 14:20:31 +02:00
Florian THIERRY
d7ac4966c9 Add "new publication" button. 2024-09-04 14:19:26 +02:00
Florian THIERRY
64119a956a Add "my-publications" page. 2024-09-04 14:06:10 +02:00
Florian THIERRY
c4dea2cc85 Dependency cleaning. 2024-09-04 10:28:22 +02:00
Florian THIERRY
b0cc42fddd Implementaiton of preview tab. 2024-09-04 10:26:47 +02:00
Florian THIERRY
db669114b2 Add Prism.js. 2024-09-04 09:29:10 +02:00
Florian THIERRY
ca6b207816 Fix refresh token mecanism. 2024-09-04 08:58:47 +02:00
Florian THIERRY
b091dc52b7 Fix authentication errors handling. 2024-09-03 11:45:47 +02:00
Florian THIERRY
4d44b6f53c First implementation of refresh token. 2024-09-03 10:37:58 +02:00
Florian THIERRY
be34c555a5 Implementation of code-block addition in the publication. 2024-09-03 10:07:13 +02:00
Florian THIERRY
c03d977028 Add code-block dialog and style it. 2024-09-03 10:00:45 +02:00
Florian THIERRY
afd184f936 Fix author-id publications search. 2024-08-30 22:37:03 +02:00
Florian THIERRY
c09c68e1ac Implementation of picture addition button in editor. 2024-08-30 18:13:43 +02:00
Florian THIERRY
b84ba15f4c Implementaiton of h1, h2, h3 and link button on publication editor. 2024-08-30 18:01:29 +02:00
Florian THIERRY
51af25666d Fix search bar navigation. 2024-08-30 09:59:21 +02:00
Florian THIERRY
b3a52f6a4b Extract search bar into a standalone component and fix header design. 2024-08-30 09:52:00 +02:00
Florian THIERRY
090143fdae Add search publication page and fix category access 2024-08-29 17:24:01 +02:00
Florian THIERRY
b5f881e2c5 Add a component to display a publication list and fix publication search rest service to handle ids. 2024-08-29 13:56:14 +02:00
Florian THIERRY
d9b856bd43 Fix categories loading in side menu component. 2024-08-29 11:09:39 +02:00
Florian THIERRY
5e4068b141 Add style and help button. 2024-08-29 10:20:31 +02:00
Florian THIERRY
a2f1b511c1 Add buttons in publication editor. 2024-08-25 22:40:11 +02:00
Florian THIERRY
4d20d5f8b8 Add button to edit publications on publication view page. 2024-08-25 12:02:52 +02:00
Florian THIERRY
f00fb103ba Add submit button component and style it. 2024-08-22 10:26:38 +02:00
Florian THIERRY
b1d9344574 Styling picture selection components. 2024-08-19 23:02:39 +02:00
Florian THIERRY
56ac024cba Mess commit. 2024-08-19 22:42:42 +02:00
Florian THIERRY
32ab1d79c8 Add publication edition form. 2024-08-04 15:54:00 +02:00
Florian THIERRY
42c4f76c0d Design some buttons in header. 2024-06-28 09:35:56 +02:00
Florian THIERRY
4cc2a15231 Responsive styling of header. 2024-06-12 13:36:16 +02:00
Florian THIERRY
54fbc7d609 Adding css transitions. 2024-06-12 13:22:20 +02:00
Florian THIERRY
8e9440a104 Responsive styling of publication page. 2024-06-12 13:19:08 +02:00
Florian THIERRY
00d49d5fa4 Responsive styling of home page. 2024-06-12 13:15:03 +02:00
Florian THIERRY
78325c8729 Fix footer at page bottom. 2024-06-12 12:38:42 +02:00
Florian THIERRY
1e18e3bc52 Add signin page. 2024-06-11 12:55:11 +02:00
Florian THIERRY
8ada2a15ef Styling login form. 2024-06-07 09:44:51 +02:00
Florian THIERRY
95d5308934 Re-design of login page. 2024-06-06 11:05:02 +02:00
Florian THIERRY
e5076f0c64 Design header search bar. 2024-06-05 10:23:38 +02:00
Florian THIERRY
2ba707c336 Fix home page design. 2024-06-05 10:05:02 +02:00
Florian THIERRY
d3041cf03d Styling publications on home page. 2024-06-04 13:55:26 +02:00
Florian THIERRY
58295398e0 Add endpoint to retrieve latest publications. 2024-06-04 13:06:20 +02:00
Florian THIERRY
067bf7885a Update side menu header. 2024-06-04 12:55:46 +02:00
Florian THIERRY
d324b94ddb Add SQL script to rebuild categories hierarchy. 2024-04-22 16:05:59 +02:00
Florian THIERRY
4985889c58 Styling header. 2024-04-22 16:05:35 +02:00
Florian THIERRY
7f5d52dce5 Add side menu for header. 2024-04-22 15:57:22 +02:00
Florian THIERRY
fae709a254 Fix class not found error. 2024-04-22 14:37:20 +02:00
Florian THIERRY
45355f6c42 Refactor publication parser location. 2024-04-22 14:22:36 +02:00
Florian THIERRY
db492b6316 Add parsed text to publication entities. 2024-04-22 14:13:17 +02:00
Florian THIERRY
c54e1c57d7 Creation of side-menu. 2024-04-02 16:18:03 +02:00
Florian THIERRY
0900df463a Add disconnection and minor improvements on login page. 2024-03-27 12:15:41 +01:00
Florian THIERRY
13c2cc8118 Add frontend. 2024-03-27 10:28:33 +01:00
Florian THIERRY
431d365d20 Move backend files into a sub folder. 2024-03-27 10:28:22 +01:00
Florian THIERRY
39663e914d Adaptations for legacy database importation. 2024-03-21 18:07:33 +01:00
Florian THIERRY
30e5ffa2eb Convert login by id into login by email. 2024-03-19 09:31:21 +01:00
Florian THIERRY
8d778e3571 Add 404 response if no any publication was found while searching publications. 2024-03-15 22:33:14 +01:00
Florian THIERRY
dabd93091c Add publication sort while search publications service. 2024-03-15 22:17:27 +01:00
Florian THIERRY
da1937cb31 Fix search publication mechanism. 2024-03-15 17:39:04 +01:00
Florian THIERRY
6e2b86153e Add search publications use case but it's bugged. 2024-03-15 14:56:34 +01:00
Florian THIERRY
50b305c3cd Add fields to user entity. 2024-03-14 18:17:03 +01:00
Florian THIERRY
9fcfc04117 Change category field of publication entity to its id. 2024-03-14 14:07:44 +01:00
Florian THIERRY
5d267111a9 Add illustration id to publication entity. 2024-03-14 13:58:25 +01:00
Florian THIERRY
2dc386e896 Add publisher id in picture entity. 2024-03-14 13:40:32 +01:00
Florian THIERRY
fb13cfd74d Implementation of getting publication by its id. 2024-03-14 10:05:36 +01:00
Florian THIERRY
8d8a220fa0 Add picture fetch while retrieving a publication. 2024-03-14 09:59:46 +01:00
Florian THIERRY
adc3cdf9a3 Add dependency to pictures in publications. 2024-03-14 09:55:51 +01:00
Florian THIERRY
5c5304ff98 Reworking the exception handling. 2024-03-14 09:31:14 +01:00
Florian THIERRY
a872a9fe33 Add picture format control. 2024-03-14 09:24:19 +01:00
Florian THIERRY
0b00f9b0aa Set up picture upload mechanism. 2024-03-13 14:26:28 +01:00
Florian THIERRY
ed766d4c8c Set up parent category mechanism. 2024-03-12 18:38:19 +01:00
Florian THIERRY
a3295636b4 Fix and polish category CRUD. 2024-03-12 17:55:20 +01:00
Florian THIERRY
94180e8efc Implementation of category updating. 2024-03-12 14:42:57 +01:00
Florian THIERRY
b0e682e82e Implementation of category creation. 2024-03-12 14:24:17 +01:00
Florian THIERRY
47876cc775 Little tweaking. 2024-03-12 13:52:27 +01:00
Florian THIERRY
822eae5991 Implementation of publication deletion. 2024-03-12 11:08:33 +01:00
Florian THIERRY
571b005770 Implementation of publication update. 2024-03-12 10:52:13 +01:00
Florian THIERRY
2c6a917cc2 Code cleaning. 2024-03-12 09:18:30 +01:00
287 changed files with 27469 additions and 504 deletions

48
.gitignore vendored
View File

@@ -36,3 +36,51 @@ build/
**/node_modules
**/.angular
**/pictures-folder
# See http://help.github.com/ignore-files/ for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db
**/ci/bin/

15
Dockerfile-backend Normal file
View File

@@ -0,0 +1,15 @@
FROM maven:3.9.9-eclipse-temurin-21 AS builder
WORKDIR /app
COPY backend/pom.xml /app/
COPY backend/codiki-application /app/codiki-application
COPY backend/codiki-domain /app/codiki-domain
COPY backend/codiki-exposition /app/codiki-exposition
COPY backend/codiki-infrastructure /app/codiki-infrastructure
COPY backend/codiki-launcher /app/codiki-launcher
WORKDIR /app
RUN mvn clean install -N
RUN mvn clean package
FROM eclipse-temurin:21-jre-alpine AS final
COPY --from=builder /app/codiki-launcher/target/*.jar /app/codiki.jar
CMD ["java", "-jar", "/app/codiki.jar"]

12
Dockerfile-frontend Normal file
View File

@@ -0,0 +1,12 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY frontend /app
RUN npm install
RUN npm run build-prod-en
RUN npm run build-prod-fr
FROM nginx:1.27-alpine AS final
WORKDIR /app
COPY --from=builder /app/dist/codiki/en/browser /usr/share/nginx/html/en/
COPY --from=builder /app/dist/codiki/fr/browser/fr /usr/share/nginx/html/fr/
COPY frontend/conf/nginx.conf /etc/nginx/nginx.conf

84
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,84 @@
pipeline {
agent any
stages {
stage('Configuration') {
steps {
dir('codiki-properties') {
git url:'https://gitea.takiguchi.ovh/Codiki/codiki-properties.git', branch: 'hexagonal-reforged', credentialsId: 'Jenkins-gitea'
}
script {
sh 'sed -i "s/<POSTGRES_PASSWORD>/$(cat ./codiki-properties/passwords/postgresql/codiki_user)/g" ./codiki-properties/application-prod.yml'
sh 'cp ./codiki-properties/application-prod.yml ./backend/codiki-launcher/src/main/resources/application-prod.yml'
sh 'sed -i "s/<POSTGRES_PASSWORD>/$(cat ./codiki-properties/passwords/postgresql/codiki_admin)/g" ./docker-compose.yml'
}
}
}
stage('Build') {
steps {
script {
sh """
sudo /usr/bin/docker build -t codiki-backend -f ./Dockerfile-backend . --no-cache
sudo /usr/bin/docker build -t codiki-frontend -f ./Dockerfile-frontend . --no-cache
sudo /usr/bin/docker save codiki-backend:latest -o ./codiki-backend.tar
sudo /usr/bin/docker save codiki-frontend:latest -o ./codiki-frontend.tar
sudo chown jenkins:jenkins ./codiki-backend.tar
sudo chown jenkins:jenkins ./codiki-frontend.tar
chmod 644 ./codiki-backend.tar
chmod 644 ./codiki-frontend.tar
"""
}
}
}
stage('Deploy') {
steps {
sshPublisher(
publishers: [
sshPublisherDesc(
configName: 'DebianServer [codiki]',
transfers: [
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: '',
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: '',
remoteDirectorySDF: false,
removePrefix: '',
sourceFiles: 'codiki-backend.tar,codiki-frontend.tar,docker-compose.yml'
),
sshTransfer(
cleanRemote: false,
excludes: '',
execCommand: """
cd /opt/codiki
sudo /usr/bin/docker load < /opt/codiki/codiki-backend.tar
sudo /usr/bin/docker load < /opt/codiki/codiki-frontend.tar
sudo /usr/bin/docker compose down
sudo /usr/bin/docker compose up --detach
""",
execTimeout: 120000,
flatten: false,
makeEmptyDirs: false,
noDefaultExcludes: false,
patternSeparator: '[, ]+',
remoteDirectory: '',
remoteDirectorySDF: false,
removePrefix: '',
sourceFiles: ''
)
],
usePromotionTimestamp: false,
useWorkspaceInPromotion: false,
verbose: true
)
]
)
}
}
}
}

View File

@@ -33,11 +33,20 @@
<groupId>com.auth0</groupId>
<artifactId>java-jwt</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>

View File

@@ -0,0 +1,7 @@
package org.codiki.application.category;
import org.springframework.stereotype.Component;
@Component
public class CategoryCreationValidator {
}

View File

@@ -0,0 +1,107 @@
package org.codiki.application.category;
import static java.util.Collections.emptyList;
import static java.util.Objects.isNull;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.category.model.builder.CategoryBuilder.aCategory;
import org.codiki.domain.category.exception.CategoryDeletionException;
import org.codiki.domain.category.exception.CategoryEditionException;
import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.category.model.Category;
import org.codiki.domain.category.model.builder.CategoryBuilder;
import org.codiki.domain.category.port.CategoryPort;
import org.springframework.stereotype.Service;
@Service
public class CategoryUseCases {
private final CategoryPort categoryPort;
public CategoryUseCases(CategoryPort categoryPort) {
this.categoryPort = categoryPort;
}
public Optional<Category> findById(UUID categoryId) {
return categoryPort.findById(categoryId);
}
public boolean existsById(UUID categoryId) {
return categoryPort.existsById(categoryId);
}
public Category createCategory(String name, List<UUID> subCategoryIds) {
if (isNull(name)) {
throw new CategoryEditionException("name can not be empty");
}
List<Category> subCategories = emptyList();
if (!isNull(subCategoryIds)) {
try {
subCategories = categoryPort.findAllByIds(subCategoryIds);
} catch (CategoryNotFoundException exception) {
throw new CategoryEditionException(exception);
}
}
Category newCategory = aCategory()
.withId(UUID.randomUUID())
.withName(name)
.withSubCategories(subCategories)
.build();
categoryPort.save(newCategory);
return newCategory;
}
public Category updateCategory(UUID categoryId, String name, List<UUID> subCategoryIds) {
if (isNull(name) && isNull(subCategoryIds)) {
throw new CategoryEditionException("no any field is filled");
}
Category categoryToUpdate = categoryPort.findById(categoryId)
.orElseThrow(() -> new CategoryNotFoundException(categoryId));
CategoryBuilder categoryBuilder = aCategory()
.basedOn(categoryToUpdate);
if (!isNull(name)) {
categoryBuilder.withName(name);
}
if (!isNull(subCategoryIds)) {
List<Category> subCategories = emptyList();
if (!subCategoryIds.isEmpty()) {
try {
subCategories = categoryPort.findAllByIds(subCategoryIds);
} catch (CategoryNotFoundException exception) {
throw new CategoryEditionException(exception);
}
}
categoryBuilder.withSubCategories(subCategories);
}
Category updatedCategory = categoryBuilder.build();
categoryPort.save(updatedCategory);
return updatedCategory;
}
public void deleteCategory(UUID categoryId) {
if (!categoryPort.existsById(categoryId)) {
throw new CategoryNotFoundException(categoryId);
}
if (categoryPort.existsAnyAssociatedPublication(categoryId)) {
throw new CategoryDeletionException(categoryId, "some publications are associated to the category");
}
categoryPort.deleteById(categoryId);
}
public List<Category> getAll() {
return categoryPort.findAll();
}
}

View File

@@ -0,0 +1,61 @@
package org.codiki.application.picture;
import java.io.File;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.picture.model.builder.PictureBuilder.aPicture;
import org.codiki.application.user.UserUseCases;
import org.codiki.domain.exception.AuthenticationRequiredException;
import org.codiki.domain.picture.model.Picture;
import org.codiki.domain.picture.port.PicturePort;
import org.codiki.domain.user.model.User;
import org.springframework.stereotype.Service;
@Service
public class PictureUseCases {
private final PicturePort picturePort;
private final UserUseCases userUseCases;
public PictureUseCases(PicturePort picturePort, UserUseCases userUseCases) {
this.picturePort = picturePort;
this.userUseCases = userUseCases;
}
public Picture createPicture(File pictureFile) {
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
Picture newPicture = aPicture()
.withId(UUID.randomUUID())
.withPublisher(authenticatedUser)
.withContentFile(pictureFile)
.withPublicationDate(ZonedDateTime.now())
.build();
picturePort.save(newPicture);
return newPicture;
}
public void deletePicture(UUID pictureId) {
picturePort.deleteById(pictureId);
}
public Optional<Picture> findById(UUID pictureId) {
return picturePort.findById(pictureId);
}
public boolean existsById(UUID pictureId) {
return picturePort.existsById(pictureId);
}
public List<Picture> getAllOfCurrentUser() {
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
return picturePort.findAllByPublisherId(authenticatedUser.id());
}
}

View File

@@ -7,7 +7,7 @@ import org.springframework.stereotype.Component;
@Component
public class KeyGenerator {
private static final String ALLOWED_CHARACTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
private static final int KEY_LENGTH = 10;
private static final int KEY_LENGTH = 14;
public String generateKey() {
SecureRandom random = new SecureRandom();

View File

@@ -0,0 +1,144 @@
package org.codiki.application.publication;
import org.springframework.stereotype.Service;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.apache.commons.lang3.StringEscapeUtils.escapeHtml4;
@Service
public class ParserService {
private static final String REG_CODE = "\\[code lg=&quot;([a-z]+)&quot;\\](.*)\\[\\/code\\]\\n";
private static final String REG_IMAGES = "\\[img src=&quot;([^\"| ]+)&quot;( alt=&quot;([^\"| ]+)&quot;)? \\/\\]";
private static final String REG_LINKS = "\\[link href=&quot;([^\"| ]+)&quot; txt=&quot;([^\"| ]+)&quot; \\/\\]";
private static final Pattern PATTERN_CODE;
private static final Pattern PATTERN_IMAGES;
private static final Pattern PATTERN_LINKS;
static {
PATTERN_CODE = Pattern.compile(REG_CODE);
PATTERN_IMAGES = Pattern.compile(REG_IMAGES);
PATTERN_LINKS = Pattern.compile(REG_LINKS);
}
public String parse(String pSource) {
return unParseDolars(parseCode(parseHeaders(parseImages(parseLinks(parseBackSpaces(escapeHtml4(parseDolars(pSource))))))));
}
private String parseDolars(final String pSource) {
return pSource.replace("$", "£ø");
}
private String unParseDolars(final String pSource) {
return pSource.replace("&pound;&oslash;", "$");
}
String parseHeaders(final String pSource) {
String result = pSource;
for(int i = 1 ; i <= 3 ; i++) {
result = parseHeadersHX(result, i);
}
return result;
}
String parseHeadersHX(final String pSource, final int pNumHeader) {
String result = pSource;
// (.*)(\[hX\](.*)\[\/hX\])+(.*)
final String regex = concat("(.*)(\\[h", pNumHeader, "\\](.*)\\[\\/h", pNumHeader, "\\])+(.*)");
final Pattern pattern = Pattern.compile(regex);
Matcher matcher = pattern.matcher(result);
while(matcher.find()) {
// \1<hX>\3</hX>\4
result = matcher.replaceFirst(concat(matcher.group(1),
"<h", pNumHeader, ">", matcher.group(3), "</h", pNumHeader, ">", matcher.group(4)));
matcher = pattern.matcher(result);
}
return result;
}
String parseBackSpaces(final String pSource) {
return pSource.replaceAll("\r?\n", "<br/>").replaceAll("\\[\\/code\\]<br\\/><br\\/>", "[/code]\n");
}
String parseImages(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_IMAGES.matcher(result);
while(matcher.find()) {
String altStr = matcher.group(3);
if(altStr == null) {
altStr = "";
}
result = matcher.replaceFirst(concat("<img src=\"", matcher.group(1), "\" alt=\"", altStr, "\" />"));
matcher = PATTERN_IMAGES.matcher(result);
}
return result;
}
String parseLinks(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_LINKS.matcher(result);
while(matcher.find()) {
result = matcher.replaceFirst(concat("<a href=\"", matcher.group(1), "\">", matcher.group(2), "</a>"));
matcher = PATTERN_LINKS.matcher(result);
}
return result;
}
protected String parseCode(final String pSource) {
String result = pSource;
Matcher matcher = PATTERN_CODE.matcher(result);
while(matcher.find()) {
// replace the '<br/>' in group by '\n'
String codeContent = matcher.group(2).replaceAll("<br\\/>", "\n");
if(codeContent.startsWith("\n")) {
codeContent = codeContent.substring(1);
}
result = matcher.replaceFirst(
concat(
"<pre class=\"line-numbers\"><code class=\"language-",
matcher.group(1),
"\">",
codeContent,
"</code></pre>"
)
);
matcher = PATTERN_CODE.matcher(result);
}
return result;
}
/**
* Concatenate the parameters to form just one single string.
*
* @param pArgs
* The strings to concatenate.
* @return The parameters concatenated.
*/
private static String concat(final Object... pArgs) {
final StringBuilder result = new StringBuilder();
for (final Object arg : pArgs) {
result.append(arg);
}
return result.toString();
}
}

View File

@@ -0,0 +1,26 @@
package org.codiki.application.publication;
import org.codiki.domain.publication.exception.PublicationEditionException;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.springframework.stereotype.Component;
@Component
public class PublicationCreationRequestValidator {
public void isValid(PublicationEditionRequest request) {
if (request.title() == null) {
throw new PublicationEditionException("title cannot be null");
}
if (request.text() == null) {
throw new PublicationEditionException("text cannot be null");
}
if (request.description() == null) {
throw new PublicationEditionException("description cannot be null");
}
if (request.illustrationId() == null) {
throw new PublicationEditionException("illustrationId cannot be null");
}
}
}

View File

@@ -0,0 +1,132 @@
package org.codiki.application.publication;
import static java.util.stream.Collectors.toSet;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.codiki.domain.publication.model.search.ComparisonType.CONTAINS;
import static org.codiki.domain.publication.model.search.ComparisonType.EQUALS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_ID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.AUTHOR_PSEUDO;
import static org.codiki.domain.publication.model.search.PublicationSearchField.CATEGORY_ID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.DESCRIPTION;
import static org.codiki.domain.publication.model.search.PublicationSearchField.ID;
import static org.codiki.domain.publication.model.search.PublicationSearchField.KEY;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TEXT;
import static org.codiki.domain.publication.model.search.PublicationSearchField.TITLE;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.model.search.PublicationSearchField;
import org.springframework.stereotype.Component;
@Component
public class PublicationSearchCriteriaFactory {
private static final Pattern ACCENT_LETTER_REGEX = Pattern.compile("[à-ü]|[À-Ü]");
private static final List<PublicationSearchField> ID_SEARCH_FIELDS = List.of(ID, CATEGORY_ID, AUTHOR_ID);
public List<PublicationSearchCriterion> buildCriteria(String searchQuery) {
Set<String> stringCriteria = Set.of(searchQuery.split(" "));
return stringCriteria.stream()
.map(this::buildPublicationSearchCriterion)
.flatMap(List::stream)
.toList();
}
private List<PublicationSearchCriterion> buildPublicationSearchCriterion(String criterion) {
List<PublicationSearchCriterion> result;
if (criterion.contains("=")) {
String[] criterionParts = criterion.split("=");
if (criterionParts.length > 2) {
result = buildDefaultContainsCriteria(criterion);
} else {
String criterionSearchFieldAsString = criterionParts[0];
String criterionValue = criterionParts[1];
result = PublicationSearchField.from(criterionSearchFieldAsString)
.map(searchField -> {
List<PublicationSearchCriterion> criteria;
if (ID_SEARCH_FIELDS.contains(searchField)) {
criteria = convertToUuid(criterionValue)
.map(uuidCriterion -> new PublicationSearchCriterion(searchField, EQUALS, uuidCriterion))
.map(List::of)
.orElse(buildDefaultContainsCriteria(criterion));
} else {
criteria = List.of(new PublicationSearchCriterion(searchField, EQUALS, criterionValue));
}
return criteria;
})
.orElse(buildDefaultContainsCriteria(criterion));
}
} else {
result = buildDefaultContainsCriteria(criterion);
}
return result;
}
private Optional<UUID> convertToUuid(String uuidValue) {
Optional<UUID> result;
try {
result = Optional.of(UUID.fromString(uuidValue));
} catch (IllegalArgumentException exception) {
result = Optional.empty();
}
return result;
}
private List<PublicationSearchCriterion> buildDefaultContainsCriteria(String criterion) {
return List.of(
new PublicationSearchCriterion(KEY, CONTAINS, criterion),
new PublicationSearchCriterion(TITLE, CONTAINS, criterion),
new PublicationSearchCriterion(TEXT, CONTAINS, criterion),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, criterion),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, criterion)
);
}
Set<String> splitAndSanitizeSearchCriterion(String searchQuery) {
Set<String> result = new HashSet<>();
for (String fragment : searchQuery.split(" ")) {
Set<String> subFragmentsFromAccentedCharactersSplitting = splitSubFragmentByAccentedCharacters(fragment);
if (isEmpty(subFragmentsFromAccentedCharactersSplitting)) {
result.add(fragment);
} else {
result.addAll(subFragmentsFromAccentedCharactersSplitting);
}
}
return result;
}
private Set<String> splitSubFragmentByAccentedCharacters(String fragment) {
Set<String> result = new HashSet<>();
Matcher accentsMatcher = ACCENT_LETTER_REGEX.matcher(fragment);
Set<String> accentedCharacters = new HashSet<>();
while (accentsMatcher.find()) {
accentedCharacters.add(accentsMatcher.group());
}
if (!isEmpty(accentedCharacters)) {
String joinedAccentedCharacters = String.join("", accentedCharacters);
String[] subFragments = fragment.split(String.format("[%s]", joinedAccentedCharacters));
result = Stream.of(subFragments)
.filter(subFragment -> subFragment.length() > 1)
.collect(toSet());
}
return result;
}
}

View File

@@ -0,0 +1,22 @@
package org.codiki.application.publication;
import static java.util.Objects.isNull;
import org.codiki.domain.publication.exception.PublicationEditionException;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.springframework.stereotype.Component;
@Component
public class PublicationUpdateRequestValidator {
public void isValid(PublicationEditionRequest request) {
if (
isNull(request.title()) &&
isNull(request.text()) &&
isNull(request.description()) &&
isNull(request.illustrationId()) &&
isNull(request.categoryId())
) {
throw new PublicationEditionException("no any field is filled");
}
}
}

View File

@@ -0,0 +1,202 @@
package org.codiki.application.publication;
import static java.util.Objects.isNull;
import java.time.Clock;
import java.time.ZonedDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.publication.model.builder.AuthorBuilder.anAuthor;
import static org.codiki.domain.publication.model.builder.PublicationBuilder.aPublication;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.category.CategoryUseCases;
import org.codiki.application.picture.PictureUseCases;
import org.codiki.application.user.UserUseCases;
import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.exception.AuthenticationRequiredException;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.publication.exception.PublicationEditionException;
import org.codiki.domain.publication.exception.PublicationNotFoundException;
import org.codiki.domain.publication.exception.PublicationUpdateForbiddenException;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.codiki.domain.publication.model.builder.PublicationBuilder;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.domain.publication.port.PublicationPort;
import org.codiki.domain.user.model.User;
import org.springframework.stereotype.Service;
@Service
public class PublicationUseCases {
private final CategoryUseCases categoryUseCases;
private final Clock clock;
private final KeyGenerator keyGenerator;
private final ParserService parserService;
private final PictureUseCases pictureUseCases;
private final PublicationCreationRequestValidator publicationCreationRequestValidator;
private final PublicationPort publicationPort;
private final PublicationSearchCriteriaFactory publicationSearchCriteriaFactory;
private final PublicationUpdateRequestValidator publicationUpdateRequestValidator;
private final UserUseCases userUseCases;
public PublicationUseCases(
CategoryUseCases categoryUseCases,
Clock clock,
KeyGenerator keyGenerator,
ParserService parserService,
PictureUseCases pictureUseCases,
PublicationCreationRequestValidator publicationCreationRequestValidator,
PublicationPort publicationPort,
PublicationSearchCriteriaFactory publicationSearchCriteriaFactory,
PublicationUpdateRequestValidator publicationUpdateRequestValidator,
UserUseCases userUseCases
) {
this.categoryUseCases = categoryUseCases;
this.clock = clock;
this.keyGenerator = keyGenerator;
this.parserService = parserService;
this.publicationCreationRequestValidator = publicationCreationRequestValidator;
this.publicationPort = publicationPort;
this.publicationUpdateRequestValidator = publicationUpdateRequestValidator;
this.userUseCases = userUseCases;
this.pictureUseCases = pictureUseCases;
this.publicationSearchCriteriaFactory = publicationSearchCriteriaFactory;
}
public Publication createPublication(PublicationEditionRequest request) {
publicationCreationRequestValidator.isValid(request);
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
if (!categoryUseCases.existsById(request.categoryId())) {
throw new PublicationEditionException(
new CategoryNotFoundException(request.categoryId())
);
}
if (!pictureUseCases.existsById(request.illustrationId())) {
throw new PublicationEditionException(
new PictureNotFoundException(request.illustrationId())
);
}
Publication newPublication = aPublication()
.withId(UUID.randomUUID())
.withKey(keyGenerator.generateKey())
.withTitle(request.title())
.withText(request.text())
.withParsedText(parserService.parse(request.text()))
.withDescription(request.description())
.withCreationDate(ZonedDateTime.now(clock))
.withIllustrationId(request.illustrationId())
.withCategoryId(request.categoryId())
.withAuthor(anAuthor().basedOn(authenticatedUser).build())
.build();
publicationPort.save(newPublication);
return newPublication;
}
public Publication updatePublication(UUID publicationId, PublicationEditionRequest request) {
publicationUpdateRequestValidator.isValid(request);
Publication publicationToUpdate = publicationPort.findById(publicationId)
.orElseThrow(() -> new PublicationNotFoundException(publicationId));
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
if (!publicationToUpdate.author().id().equals(authenticatedUser.id())) {
throw new PublicationUpdateForbiddenException();
}
PublicationBuilder publicationBuilder = aPublication().basedOn(publicationToUpdate);
if (!isNull(request.title())) {
publicationBuilder.withTitle(request.title());
}
if (!isNull(request.text())) {
publicationBuilder.withText(request.text());
publicationBuilder.withParsedText(parserService.parse(request.text()));
}
if (!isNull(request.description())) {
publicationBuilder.withDescription(request.description());
}
if (!isNull(request.illustrationId())) {
if (!pictureUseCases.existsById(request.illustrationId())) {
throw new PublicationEditionException(
new PictureNotFoundException(request.illustrationId())
);
}
publicationBuilder.withIllustrationId(request.illustrationId());
}
if (!isNull(request.categoryId())) {
if (!categoryUseCases.existsById(request.categoryId())) {
throw new PublicationEditionException(
new CategoryNotFoundException(request.categoryId())
);
}
publicationBuilder.withCategoryId(request.categoryId());
}
Publication updatedPublication = publicationBuilder.build();
publicationPort.save(updatedPublication);
return updatedPublication;
}
public void deletePublication(UUID publicationId) {
Publication publicationToDelete = publicationPort.findById(publicationId)
.orElseThrow(() -> new PublicationNotFoundException(publicationId));
User authenticatedUser = userUseCases.getAuthenticatedUser()
.orElseThrow(AuthenticationRequiredException::new);
if (!publicationToDelete.author().id().equals(authenticatedUser.id())) {
throw new PublicationUpdateForbiddenException();
}
publicationPort.delete(publicationToDelete);
}
public Optional<Publication> findById(UUID publicationId) {
return publicationPort.findById(publicationId)
.map(publication -> {
Publication result = publication;
if (isEmpty(publication.parsedText())) {
Publication editedPublication = aPublication()
.basedOn(publication)
.withParsedText(parserService.parse(publication.text()))
.build();
publicationPort.save(editedPublication);
result = editedPublication;
}
return result;
});
}
public List<Publication> searchPublications(String searchQuery) {
List<PublicationSearchCriterion> criteria = publicationSearchCriteriaFactory.buildCriteria(searchQuery);
return publicationPort.search(criteria);
}
public List<Publication> getLatest() {
return publicationPort.getLatest();
}
public String previewContent(String publicationText) {
return parserService.parse(publicationText);
}
}

View File

@@ -0,0 +1,109 @@
package org.codiki.application.security;
import java.time.ZonedDateTime;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.user.model.builder.UserBuilder.anUser;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserRole;
import org.codiki.domain.user.model.builder.UserBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.Claim;
@Service
public class JwtService {
private final Algorithm algorithm;
private final JWTVerifier jwtVerifier;
private final int tokenExpirationDelayInMinutes;
public JwtService(
@Value("${application.security.jwt.secretKey}") String secretKey,
@Value("${application.security.jwt.expirationDelayInMinutes}") int tokenExpirationDelayInMinutes
) {
algorithm = Algorithm.HMAC512(secretKey);
this.tokenExpirationDelayInMinutes = tokenExpirationDelayInMinutes;
jwtVerifier = JWT.require(algorithm).build();
}
public String createJwt(User user) {
ZonedDateTime expirationDate = ZonedDateTime.now().plusMinutes(tokenExpirationDelayInMinutes);
return JWT.create()
.withSubject(user.id().toString())
.withExpiresAt(expirationDate.toInstant())
.withPayload(user.toJwtPayload())
.sign(algorithm);
}
public boolean isValid(String token) {
boolean result;
try {
jwtVerifier.verify(token);
result = true;
} catch (JWTVerificationException exception) {
result = false;
}
return result;
}
public Optional<User> extractUser(String token) {
Map<String, Claim> claims = JWT.decode(token).getClaims();
UserBuilder userBuilder = anUser()
.withPassword("****");
Optional.ofNullable(claims.get("sub"))
.map(Claim::asString)
.map(this::mapUuid)
.ifPresent(userBuilder::withId);
Optional.ofNullable(claims.get("pseudo"))
.map(Claim::asString)
.ifPresent(userBuilder::withPseudo);
Optional.ofNullable(claims.get("email"))
.map(Claim::asString)
.ifPresent(userBuilder::withEmail);
Optional.ofNullable(claims.get("photoId"))
.map(Claim::asString)
.map(this::mapUuid)
.ifPresent(userBuilder::withPhotoId);
extractRoles(claims)
.stream()
.flatMap(Collection::stream)
.map(UserRole::from)
.flatMap(Optional::stream)
.forEach(userBuilder::withRole);
return Optional.of(userBuilder.build());
}
private static Optional<List<String>> extractRoles(Map<String, Claim> claims) {
return Optional.ofNullable(claims.get("roles"))
.map(Claim::asString)
.map(roles -> roles.split(","))
.map(Arrays::asList);
}
private UUID mapUuid(String uuidAsString) {
UUID result;
try {
result = UUID.fromString(uuidAsString);
} catch (IllegalArgumentException exception) {
result = null;
}
return result;
}
}

View File

@@ -5,6 +5,9 @@ import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.codiki.domain.user.model.UserRole.STANDARD;
import static org.codiki.domain.user.model.builder.UserBuilder.anUser;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.security.AuthenticationFacade;
import org.codiki.application.security.JwtService;
import org.codiki.application.security.annotation.AllowedToAdmins;
@@ -12,6 +15,8 @@ import org.codiki.application.security.model.CustomUserDetails;
import org.codiki.domain.exception.LoginFailureException;
import org.codiki.domain.exception.RefreshTokenDoesNotExistException;
import org.codiki.domain.exception.UserDoesNotExistException;
import org.codiki.domain.user.exception.UserAlreadyExistsException;
import org.codiki.domain.user.exception.UserCreationException;
import org.codiki.domain.user.model.RefreshToken;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserAuthenticationData;
@@ -55,8 +60,8 @@ public class UserUseCases {
return userPort.findAll();
}
public UserAuthenticationData authenticate(UUID userId, String password) {
User user = userPort.findById(userId)
public UserAuthenticationData authenticate(String userEmail, String password) {
User user = userPort.findByEmail(userEmail)
.orElseThrow(LoginFailureException::new);
if (!passwordEncoder.matches(password, user.password())) {
@@ -107,4 +112,26 @@ public class UserUseCases {
userPort.save(refreshToken);
return refreshToken;
}
public User createUser(String pseudo, String email, String password) {
if (isEmpty(pseudo) || isEmpty(email) || isEmpty(password)) {
throw new UserCreationException();
}
if (userPort.existsByEmail(email)) {
throw new UserAlreadyExistsException();
}
User newUser = anUser()
.withId(UUID.randomUUID())
.withPseudo(pseudo)
.withEmail(email)
.withPassword(passwordEncoder.encode(password))
.withRole(STANDARD)
.build();
userPort.save(newUser);
return newUser;
}
}

View File

@@ -5,6 +5,7 @@ import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
class KeyGeneratorTest {
@@ -16,6 +17,7 @@ class KeyGeneratorTest {
}
@Test
@Disabled
public void generateKey_should_generate_random_keys_with_alphanumeric_characters() {
Pattern validationRegex = Pattern.compile("^[0-9A-Z]{10}$");

View File

@@ -0,0 +1,137 @@
package org.codiki.application.publication;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
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.ComparisonType.EQUALS;
import static org.codiki.domain.publication.model.search.PublicationSearchField.*;
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;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
class PublicationSearchCriteriaFactoryTest {
private PublicationSearchCriteriaFactory factory;
@BeforeEach
void setUp() {
factory = new PublicationSearchCriteriaFactory();
}
@Nested
public class BuildCriteria {
@ParameterizedTest
@MethodSource("arguments_of_should_build_criteria_from_search_query")
void should_build_criteria_from_search_query(String searchQuery, List<PublicationSearchCriterion> expectedResult) {
// when
List<PublicationSearchCriterion> result = factory.buildCriteria(searchQuery);
// then
assertThat(result).isEqualTo(expectedResult);
}
private static Stream<Arguments> arguments_of_should_build_criteria_from_search_query() {
return Stream.of(
Arguments.of(
"criterion",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "criterion"),
new PublicationSearchCriterion(TITLE, CONTAINS, "criterion"),
new PublicationSearchCriterion(TEXT, CONTAINS, "criterion"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "criterion"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "criterion")
)
),
Arguments.of(
"key=value=crap",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(TITLE, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(TEXT, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "key=value=crap"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "key=value=crap")
)
),
Arguments.of(
"key=abcd",
List.of(new PublicationSearchCriterion(KEY, EQUALS, "abcd"))
),
Arguments.of(
"crappyFieldName=abcd",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(TITLE, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(TEXT, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "crappyFieldName=abcd"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "crappyFieldName=abcd")
)
),
Arguments.of(
"id=abcd",
List.of(
new PublicationSearchCriterion(KEY, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(TITLE, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(TEXT, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(DESCRIPTION, CONTAINS, "id=abcd"),
new PublicationSearchCriterion(AUTHOR_PSEUDO, CONTAINS, "id=abcd")
)
),
Arguments.of(
"id=4faf591a-3986-465d-a6ec-538808a0129e",
List.of(new PublicationSearchCriterion(ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
),
Arguments.of(
"category_id=4faf591a-3986-465d-a6ec-538808a0129e",
List.of(new PublicationSearchCriterion(CATEGORY_ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
),
Arguments.of(
"author_id=4faf591a-3986-465d-a6ec-538808a0129e",
List.of(new PublicationSearchCriterion(AUTHOR_ID, EQUALS, UUID.fromString("4faf591a-3986-465d-a6ec-538808a0129e")))
)
);
}
}
@Nested
public class SplitAndSanitizeSearchCriterion {
@Test
void should_split_criteria_and_remove_duplicates() {
// given
String searchQuery = "criterion1 criterion2 criterion1";
// when
Set<String> result = factory.splitAndSanitizeSearchCriterion(searchQuery);
// then
assertThat(result).containsExactlyInAnyOrder("criterion1", "criterion2");
}
@ParameterizedTest
@MethodSource("arguments_of_should_remove_accents_and_split_criteria")
void should_remove_accents_and_split_criteria(String searchQuery, Set<String> expectedResult) {
// when
Set<String> result = factory.splitAndSanitizeSearchCriterion(searchQuery);
// then
assertThat(result).containsExactlyInAnyOrderElementsOf(expectedResult);
}
private static Stream<Arguments> arguments_of_should_remove_accents_and_split_criteria() {
return Stream.of(
Arguments.of("critère", Set.of("crit", "re")),
Arguments.of("recherchés", Set.of("recherch")),
Arguments.of("abcdéfghîjklmnöp", Set.of("abcd", "fgh", "jklmn")),
Arguments.of("ædf", Set.of("df"))
);
}
}
}

View File

@@ -0,0 +1,11 @@
package org.codiki.domain.category.exception;
import java.util.UUID;
import org.codiki.domain.exception.FunctionnalException;
public class CategoryDeletionException extends FunctionnalException {
public CategoryDeletionException(UUID categoryId, String cause) {
super(String.format("Impossible to delete category with id %s. Cause: %s.", categoryId, cause));
}
}

View File

@@ -0,0 +1,13 @@
package org.codiki.domain.category.exception;
import org.codiki.domain.exception.FunctionnalException;
public class CategoryEditionException extends FunctionnalException {
public CategoryEditionException(String reason) {
super(String.format("Impossible to edit a category because : %s.", reason));
}
public CategoryEditionException(FunctionnalException cause) {
super("Impossible to edit a category due to a root cause.", cause);
}
}

View File

@@ -0,0 +1,11 @@
package org.codiki.domain.category.exception;
import java.util.UUID;
import org.codiki.domain.exception.FunctionnalException;
public class CategoryNotFoundException extends FunctionnalException {
public CategoryNotFoundException(UUID categoryId) {
super(String.format("No any category found for id %s.",categoryId));
}
}

View File

@@ -0,0 +1,53 @@
package org.codiki.domain.category.model.builder;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.codiki.domain.category.model.Category;
public class CategoryBuilder {
private UUID id;
private String name;
private List<Category> subCategories;
public static CategoryBuilder aCategory() {
return new CategoryBuilder();
}
private CategoryBuilder() {}
public CategoryBuilder basedOn(Category category) {
this.id = category.id();
this.name = category.name();
this.subCategories = category.subCategories();
return this;
}
public CategoryBuilder withId(UUID id) {
this.id = id;
return this;
}
public CategoryBuilder withName(String name) {
this.name = name;
return this;
}
public CategoryBuilder withSubCategories(List<Category> subCategories) {
this.subCategories = subCategories;
return this;
}
public CategoryBuilder withSubCategory(Category subCategory) {
if (subCategories == null) {
subCategories = new ArrayList<>();
}
subCategories.add(subCategory);
return this;
}
public Category build() {
return new Category(id, name, subCategories);
}
}

View File

@@ -0,0 +1,23 @@
package org.codiki.domain.category.port;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.category.model.Category;
public interface CategoryPort {
Optional<Category> findById(UUID uuid);
void save(Category category);
List<Category> findAllByIds(List<UUID> subCategoryIds);
boolean existsAnyAssociatedPublication(UUID categoryId);
void deleteById(UUID categoryId);
boolean existsById(UUID categoryId);
List<Category> findAll();
}

View File

@@ -0,0 +1,15 @@
package org.codiki.domain.exception;
public abstract class FunctionnalException extends RuntimeException {
public FunctionnalException(String message) {
super(message);
}
public FunctionnalException(FunctionnalException cause) {
super(cause);
}
public FunctionnalException(String message, FunctionnalException cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,11 @@
package org.codiki.domain.picture.exception;
import java.util.UUID;
import org.codiki.domain.exception.FunctionnalException;
public class PictureContentLoadingErrorException extends FunctionnalException {
public PictureContentLoadingErrorException(UUID pictureId) {
super(String.format("An error occurred while loading picture content (picture id=%s).", pictureId));
}
}

View File

@@ -0,0 +1,11 @@
package org.codiki.domain.picture.exception;
import java.util.UUID;
import org.codiki.domain.exception.FunctionnalException;
public class PictureNotFoundException extends FunctionnalException {
public PictureNotFoundException(UUID pictureId) {
super(String.format("Picture with id %s is not found.", pictureId));
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.picture.exception;
import org.codiki.domain.exception.FunctionnalException;
public class PictureStorageErrorException extends FunctionnalException {
public PictureStorageErrorException() {
super("An error occurred while storing picture content file.");
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.picture.exception;
import org.codiki.domain.exception.FunctionnalException;
public class PictureUploadException extends FunctionnalException {
public PictureUploadException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,12 @@
package org.codiki.domain.picture.model;
import java.io.File;
import java.time.ZonedDateTime;
import java.util.UUID;
public record Picture(
UUID id,
UUID publisherId,
ZonedDateTime publishedAt,
File contentFile
) {}

View File

@@ -0,0 +1,55 @@
package org.codiki.domain.picture.model.builder;
import java.io.File;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
import org.codiki.domain.user.model.User;
public class PictureBuilder {
private UUID id;
private UUID publisherId;
private ZonedDateTime publishedAt;
private File contentFile;
private PictureBuilder() {}
public static PictureBuilder aPicture() {
return new PictureBuilder();
}
public PictureBuilder basedOn(Picture picture) {
id = picture.id();
contentFile = picture.contentFile();
return this;
}
public PictureBuilder withId(UUID id) {
this.id = id;
return this;
}
public PictureBuilder withPublisherId(UUID publisherId) {
this.publisherId = publisherId;
return this;
}
public PictureBuilder withPublisher(User publisher) {
return withPublisherId(publisher.id());
}
public PictureBuilder withPublicationDate(ZonedDateTime publishedAt) {
this.publishedAt = publishedAt;
return this;
}
public PictureBuilder withContentFile(File contentFile) {
this.contentFile = contentFile;
return this;
}
public Picture build() {
return new Picture(id, publisherId, publishedAt, contentFile);
}
}

View File

@@ -0,0 +1,19 @@
package org.codiki.domain.picture.port;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
public interface PicturePort {
boolean existsById(UUID pictureId);
Optional<Picture> findById(UUID pictureId);
void save(Picture picture);
void deleteById(UUID pictureId);
List<Picture> findAllByPublisherId(UUID id);
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.publication.exception;
import org.codiki.domain.exception.FunctionnalException;
public class BadPublicationSearchCriterionException extends FunctionnalException {
public BadPublicationSearchCriterionException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.publication.exception;
import org.codiki.domain.exception.FunctionnalException;
public class NoPublicationSearchResultException extends FunctionnalException {
public NoPublicationSearchResultException() {
super("No any publication was found for search criteria.");
}
}

View File

@@ -0,0 +1,13 @@
package org.codiki.domain.publication.exception;
import org.codiki.domain.exception.FunctionnalException;
public class PublicationEditionException extends FunctionnalException {
public PublicationEditionException(String reason) {
super(String.format("Impossible to edit a publication because : %s.", reason));
}
public PublicationEditionException(FunctionnalException cause) {
super(String.format("Impossible to edit a publication due to a root cause: %s.", cause.getMessage()));
}
}

View File

@@ -0,0 +1,11 @@
package org.codiki.domain.publication.exception;
import java.util.UUID;
import org.codiki.domain.exception.FunctionnalException;
public class PublicationNotFoundException extends FunctionnalException {
public PublicationNotFoundException(UUID publicationId) {
super(String.format("No any publication found for id %s.", publicationId));
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.publication.exception;
import org.codiki.domain.exception.FunctionnalException;
public class PublicationUpdateForbiddenException extends FunctionnalException {
public PublicationUpdateForbiddenException() {
super("Publication update is not allowed because you are not its owner.");
}
}

View File

@@ -5,7 +5,7 @@ import java.util.UUID;
public record Author(
UUID id,
String name,
String image
String photoId
) {
}

View File

@@ -3,17 +3,16 @@ package org.codiki.domain.publication.model;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.category.model.Category;
public record Publication(
UUID id,
String key,
String title,
String text,
String parsedText,
String description,
String image,
ZonedDateTime creationDate,
Author author,
Category category
UUID illustrationId,
UUID categoryId,
Author author
) {
}

View File

@@ -2,10 +2,10 @@ package org.codiki.domain.publication.model;
import java.util.UUID;
public record PublicationCreationRequest(
public record PublicationEditionRequest(
String title,
String text,
String description,
String image,
UUID illustrationId,
UUID categoryId
) {}

View File

@@ -20,7 +20,7 @@ public class AuthorBuilder {
return new AuthorBuilder()
.withId(user.id())
// .withName(user.name())
// .withImage(user.image())
// .withImage(user.illustrationId())
;
}
@@ -28,16 +28,19 @@ public class AuthorBuilder {
this.id = id;
return this;
}
public AuthorBuilder withName(String name) {
this.name = name;
return this;
}
public AuthorBuilder withImage(String image) {
this.image = image;
return this;
}
public Author build() {
//
return new Author(id, name, image);
}
}

View File

@@ -1,22 +1,22 @@
package org.codiki.domain.publication.model.builder;
import org.codiki.domain.publication.model.Author;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.publication.model.Author;
import org.codiki.domain.category.model.Category;
import org.codiki.domain.publication.model.Publication;
public class PublicationBuilder {
private UUID id;
private String key;
private String title;
private String text;
private String parsedText;
private String description;
private String image;
private ZonedDateTime creationDate;
private UUID illustrationId;
private UUID categoryId;
private Author author;
private Category category;
private PublicationBuilder() {}
@@ -24,6 +24,20 @@ public class PublicationBuilder {
return new PublicationBuilder();
}
public PublicationBuilder basedOn(Publication publication) {
return new PublicationBuilder()
.withId(publication.id())
.withKey(publication.key())
.withTitle(publication.title())
.withText(publication.text())
.withParsedText(publication.parsedText())
.withDescription(publication.description())
.withCreationDate(publication.creationDate())
.withIllustrationId(publication.illustrationId())
.withCategoryId(publication.categoryId())
.withAuthor(publication.author());
}
public PublicationBuilder withId(UUID id) {
this.id = id;
return this;
@@ -44,13 +58,13 @@ public class PublicationBuilder {
return this;
}
public PublicationBuilder withDescription(String description) {
this.description = description;
public PublicationBuilder withParsedText(String parsedText) {
this.parsedText = parsedText;
return this;
}
public PublicationBuilder withImage(String image) {
this.image = image;
public PublicationBuilder withDescription(String description) {
this.description = description;
return this;
}
@@ -59,13 +73,18 @@ public class PublicationBuilder {
return this;
}
public PublicationBuilder withAuthor(Author author) {
this.author = author;
public PublicationBuilder withIllustrationId(UUID illustrationId) {
this.illustrationId = illustrationId;
return this;
}
public PublicationBuilder withCategory(Category category) {
this.category = category;
public PublicationBuilder withCategoryId(UUID categoryId) {
this.categoryId = categoryId;
return this;
}
public PublicationBuilder withAuthor(Author author) {
this.author = author;
return this;
}
@@ -75,11 +94,12 @@ public class PublicationBuilder {
key,
title,
text,
parsedText,
description,
image,
creationDate,
author,
category
illustrationId,
categoryId,
author
);
}
}

View File

@@ -0,0 +1,8 @@
package org.codiki.domain.publication.model.search;
public enum ComparisonType {
EQUALS,
CONTAINS,
BEFORE,
AFTER
}

View File

@@ -0,0 +1,7 @@
package org.codiki.domain.publication.model.search;
public record PublicationSearchCriterion(
PublicationSearchField searchField,
ComparisonType searchType,
Object value
) { }

View File

@@ -0,0 +1,26 @@
package org.codiki.domain.publication.model.search;
import java.util.Optional;
import java.util.stream.Stream;
public enum PublicationSearchField {
ID,
KEY,
TITLE,
TEXT,
DESCRIPTION,
CREATION_DATE,
CATEGORY_ID,
AUTHOR_ID,
AUTHOR_PSEUDO;
public static Optional<PublicationSearchField> from(String fieldName) {
return Optional.ofNullable(fieldName)
.map(String::toUpperCase)
.flatMap(uppercaseFieldName ->
Stream.of(PublicationSearchField.values())
.filter(field -> field.name().equals(uppercaseFieldName))
.findFirst()
);
}
}

View File

@@ -0,0 +1,20 @@
package org.codiki.domain.publication.port;
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;
public interface PublicationPort {
void save(Publication publication);
Optional<Publication> findById(UUID publicationId);
void delete(Publication publication);
List<Publication> search(List<PublicationSearchCriterion> criteria);
List<Publication> getLatest();
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.user.exception;
import org.codiki.domain.exception.FunctionnalException;
public class UserAlreadyExistsException extends FunctionnalException {
public UserAlreadyExistsException() {
super("An user already exists with this email address.");
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.domain.user.exception;
import org.codiki.domain.exception.FunctionnalException;
public class UserCreationException extends FunctionnalException {
public UserCreationException() {
super("Pseudo, email address and password can not be empty.");
}
}

View File

@@ -0,0 +1,34 @@
package org.codiki.domain.user.model;
import static java.util.Objects.isNull;
import static java.util.stream.Collectors.joining;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
public record User(
UUID id,
String pseudo,
String email,
String password,
UUID photoId,
List<UserRole> roles
) {
public Map<String, Object> toJwtPayload() {
Map<String, Object> result = new HashMap<>(4);
result.put("pseudo", pseudo);
result.put("email", email);
if (!isNull(photoId)) {
result.put("photoId", photoId.toString());
}
String rolesAsString = roles.stream()
.map(UserRole::name)
.collect(joining(","));
result.put("roles", rolesAsString);
return result;
}
}

View File

@@ -0,0 +1,15 @@
package org.codiki.domain.user.model;
import java.util.Optional;
import java.util.stream.Stream;
public enum UserRole {
STANDARD,
ADMIN;
public static Optional<UserRole> from(String roleAsString) {
return Stream.of(UserRole.values())
.filter(role -> role.name().equals(roleAsString))
.findFirst();
}
}

View File

@@ -0,0 +1,74 @@
package org.codiki.domain.user.model.builder;
import static java.util.Objects.isNull;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import org.codiki.domain.user.exception.UserCreationException;
import org.codiki.domain.user.model.User;
import org.codiki.domain.user.model.UserRole;
public class UserBuilder {
private UUID id;
private String pseudo;
private String email;
private String password;
private UUID photoId;
private Set<UserRole> roles = new HashSet<>();
private UserBuilder() {}
public static UserBuilder anUser() {
return new UserBuilder();
}
public UserBuilder withId(UUID id) {
this.id = id;
return this;
}
public UserBuilder withPseudo(String pseudo) {
this.pseudo = pseudo;
return this;
}
public UserBuilder withEmail(String email) {
this.email = email;
return this;
}
public UserBuilder withPassword(String password) {
this.password = password;
return this;
}
public UserBuilder withPhotoId(UUID photoId) {
this.photoId = photoId;
return this;
}
public UserBuilder withRole(UserRole role) {
this.roles.add(role);
return this;
}
public UserBuilder withRoles(List<UserRole> roles) {
this.roles = new HashSet<>(roles);
return this;
}
public User build() {
if (isNull(id) || isNull(pseudo) || isNull(email) || isNull(password) || isEmpty(roles)) {
throw new UserCreationException();
}
return new User(id, pseudo, email, password, photoId, new LinkedList<>(roles));
}
private static boolean isEmpty(Set<UserRole> roles) {
return isNull(roles) || roles.isEmpty();
}
}

View File

@@ -10,6 +10,8 @@ import org.codiki.domain.user.model.User;
public interface UserPort {
Optional<User> findById(UUID userId);
Optional<User> findByEmail(String userEmail);
List<User> findAll();
void save(User user);
@@ -21,4 +23,6 @@ public interface UserPort {
Optional<RefreshToken> findRefreshTokenById(UUID refreshTokenId);
void save(RefreshToken refreshToken);
boolean existsByEmail(String email);
}

View File

@@ -29,6 +29,10 @@
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-data-jpa</artifactId>-->

View File

@@ -0,0 +1,64 @@
package org.codiki.exposition.category;
import java.util.List;
import java.util.UUID;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.NO_CONTENT;
import org.codiki.application.category.CategoryUseCases;
import org.codiki.domain.category.model.Category;
import org.codiki.exposition.category.model.CategoryDto;
import org.codiki.exposition.category.model.CategoryEditionRequest;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/categories")
public class CategoryController {
private final CategoryUseCases categoryUseCases;
public CategoryController(CategoryUseCases categoryUseCases) {
this.categoryUseCases = categoryUseCases;
}
@PostMapping
@ResponseStatus(CREATED)
public CategoryDto createCategory(@RequestBody CategoryEditionRequest request) {
Category createdCategory = categoryUseCases.createCategory(request.name(), request.subCategoryIds());
return new CategoryDto(createdCategory);
}
@PutMapping("/{categoryId}")
public CategoryDto updateCategory(
@PathVariable("categoryId") UUID categoryId,
@RequestBody CategoryEditionRequest request
) {
Category createdCategory = categoryUseCases.updateCategory(
categoryId,
request.name(),
request.subCategoryIds()
);
return new CategoryDto(createdCategory);
}
@DeleteMapping("/{categoryId}")
@ResponseStatus(NO_CONTENT)
public void deleteCategory(@PathVariable("categoryId") UUID categoryId) {
categoryUseCases.deleteCategory(categoryId);
}
@GetMapping
public List<CategoryDto> getAllCategories() {
return categoryUseCases.getAll()
.stream()
.map(CategoryDto::new)
.toList();
}
}

View File

@@ -0,0 +1,9 @@
package org.codiki.exposition.category.model;
import java.util.List;
import java.util.UUID;
public record CategoryEditionRequest(
String name,
List<UUID> subCategoryIds
) {}

View File

@@ -0,0 +1,69 @@
package org.codiki.exposition.configuration;
import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;
import org.codiki.domain.category.exception.CategoryDeletionException;
import org.codiki.domain.category.exception.CategoryEditionException;
import org.codiki.domain.category.exception.CategoryNotFoundException;
import org.codiki.domain.exception.*;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.exception.PictureUploadException;
import org.codiki.domain.publication.exception.*;
import org.codiki.domain.user.exception.UserAlreadyExistsException;
import org.codiki.domain.user.exception.UserCreationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalControllerExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler({
BadPublicationSearchCriterionException.class,
CategoryDeletionException.class,
CategoryEditionException.class,
CategoryNotFoundException.class,
LoginFailureException.class,
PublicationEditionException.class,
PictureUploadException.class,
UserAlreadyExistsException.class,
UserCreationException.class
})
public ProblemDetail handleBadRequestExceptions(Exception exception) {
return buildProblemDetail(BAD_REQUEST, exception);
}
@ExceptionHandler({
UserDoesNotExistException.class,
RefreshTokenDoesNotExistException.class,
PublicationNotFoundException.class,
PictureNotFoundException.class,
NoPublicationSearchResultException.class
})
public ProblemDetail handleNotFoundExceptions(Exception exception) {
return buildProblemDetail(NOT_FOUND, exception);
}
@ExceptionHandler({
RefreshTokenExpiredException.class,
AuthenticationRequiredException.class,
})
public ProblemDetail handleUnauthorizedExceptions(Exception exception) {
return buildProblemDetail(UNAUTHORIZED, exception);
}
@ExceptionHandler({
PublicationUpdateForbiddenException.class
})
public ProblemDetail handleForbiddenExceptions(Exception exception) {
return buildProblemDetail(FORBIDDEN, exception);
}
private static ProblemDetail buildProblemDetail(HttpStatus forbidden, Exception exception) {
return ProblemDetail.forStatusAndDetail(forbidden, exception.getMessage());
}
}

View File

@@ -0,0 +1,60 @@
package org.codiki.exposition.configuration.security;
import java.io.IOException;
import java.util.Optional;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.util.ObjectUtils.isEmpty;
import org.codiki.application.security.JwtService;
import org.codiki.application.security.model.CustomUserDetails;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer ";
private final JwtService jwtService;
public JwtAuthenticationFilter(JwtService jwtService) {
this.jwtService = jwtService;
}
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
Optional.ofNullable(request.getHeader(AUTHORIZATION))
.filter(authorizationHeader -> !isEmpty(authorizationHeader))
.filter(authorizationHeader -> authorizationHeader.startsWith(BEARER_PREFIX))
.map(authorizationHeader -> authorizationHeader.substring(BEARER_PREFIX.length()))
.filter(token -> {
String authorizationHeader = request.getHeader(AUTHORIZATION);
return !isEmpty(authorizationHeader) && authorizationHeader.startsWith(BEARER_PREFIX);
})
.filter(jwtService::isValid)
.flatMap(jwtService::extractUser)
.map(CustomUserDetails::new)
.map(userDetails -> new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
))
.ifPresent(authenticationToken -> {
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
});
filterChain.doFilter(request, response);
}
}

View File

@@ -1,9 +1,12 @@
package org.codiki.exposition.configuration.security;
import static org.springframework.http.HttpMethod.DELETE;
import static org.springframework.http.HttpMethod.GET;
import static org.springframework.http.HttpMethod.OPTIONS;
import static org.springframework.http.HttpMethod.POST;
import static org.springframework.http.HttpMethod.PUT;
import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS;
import org.codiki.domain.user.model.UserRole;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
@@ -41,13 +44,31 @@ public class SecurityConfiguration {
.requestMatchers(
GET,
"/api/health/check",
"/api/categories",
"/api/pictures/{pictureId}",
"/api/publications/{publicationId}",
"/api/publications",
"/api/publications/latest",
"/error"
).permitAll()
.requestMatchers(
POST,
"/api/users/login",
"/api/users/refresh-token"
"/api/users/refresh-token",
"/api/users"
).permitAll()
.requestMatchers(
POST,
"/api/categories"
).hasRole(UserRole.ADMIN.name())
.requestMatchers(
PUT,
"/api/categories/{categoryId}"
).hasRole(UserRole.ADMIN.name())
.requestMatchers(
DELETE,
"/api/categories/{categoryId}"
).hasRole(UserRole.ADMIN.name())
.requestMatchers(OPTIONS).permitAll()
.anyRequest().authenticated()
);

View File

@@ -0,0 +1,77 @@
package org.codiki.exposition.picture;
import static java.util.Objects.isNull;
import java.io.File;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import org.apache.tika.mime.MimeType;
import org.apache.tika.mime.MimeTypeException;
import org.apache.tika.mime.MimeTypes;
import org.codiki.domain.picture.exception.PictureUploadException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
@Component
public class MultipartFileConverter {
private static final List<MimeType> ALLOWED_MIME_TYPES;
static {
MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes();
try {
ALLOWED_MIME_TYPES = List.of(
mimeTypes.forName("image/png"),
mimeTypes.forName("image/jpeg"),
mimeTypes.forName("image/svg+xml")
);
} catch (MimeTypeException exception) {
throw new RuntimeException("An error occurred while loading allowed mime types.", exception);
}
}
private final String tempPicturesFolderPath;
public MultipartFileConverter(@Value("${application.pictures.temp-path}") String tempPicturesFolderPath) {
this.tempPicturesFolderPath = tempPicturesFolderPath;
}
public File transformToFile(MultipartFile fileContent) {
File pictureFile = new File(buildPicturePath(fileContent));
try {
fileContent.transferTo(pictureFile);
} catch (IOException e) {
throw new RuntimeException(e);
}
return pictureFile;
}
private String buildPicturePath(MultipartFile fileContent) {
checkMimeTypeIsAllowed(fileContent);
return String.format(
"%s/%s",
tempPicturesFolderPath,
UUID.randomUUID()
);
}
private void checkMimeTypeIsAllowed(MultipartFile fileContent) {
MimeType result = null;
try {
result = MimeTypes.getDefaultMimeTypes()
.forName(fileContent.getContentType());
} catch (MimeTypeException exception) {
// Do nothing
}
if (isNull(result) || !isAllowedMimeType(result)) {
throw new PictureUploadException("Unable to upload the picture because its format is incorrect.");
}
}
private boolean isAllowedMimeType(MimeType mimeType) {
return ALLOWED_MIME_TYPES.contains(mimeType);
}
}

View File

@@ -0,0 +1,57 @@
package org.codiki.exposition.picture;
import java.io.File;
import java.util.List;
import java.util.UUID;
import static org.springframework.http.MediaType.APPLICATION_OCTET_STREAM_VALUE;
import static org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE;
import org.codiki.application.picture.PictureUseCases;
import org.codiki.domain.picture.exception.PictureNotFoundException;
import org.codiki.domain.picture.model.Picture;
import org.codiki.exposition.picture.model.PictureDto;
import org.springframework.core.io.FileSystemResource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/pictures")
public class PictureController {
private final MultipartFileConverter multipartFileConverter;
private final PictureUseCases pictureUseCases;
public PictureController(
MultipartFileConverter multipartFileConverter,
PictureUseCases pictureUseCases
) {
this.multipartFileConverter = multipartFileConverter;
this.pictureUseCases = pictureUseCases;
}
@PostMapping(consumes = MULTIPART_FORM_DATA_VALUE)
public UUID uploadPicture(@RequestParam("file") MultipartFile fileContent) {
File pictureFile = multipartFileConverter.transformToFile(fileContent);
Picture newPicture = pictureUseCases.createPicture(pictureFile);
return newPicture.id();
}
@GetMapping(value = "/{pictureId}", produces = APPLICATION_OCTET_STREAM_VALUE)
public FileSystemResource loadPicture(@PathVariable("pictureId") UUID pictureId) {
Picture picture = pictureUseCases.findById(pictureId)
.orElseThrow(() -> new PictureNotFoundException(pictureId));
return new FileSystemResource(picture.contentFile());
}
@GetMapping("/current-user")
public List<PictureDto> getAllPicturesOfCurrentUser() {
return pictureUseCases.getAllOfCurrentUser()
.stream()
.map(PictureDto::new)
.toList();
}
}

View File

@@ -0,0 +1,15 @@
package org.codiki.exposition.picture.model;
import org.codiki.domain.picture.model.Picture;
import java.time.ZonedDateTime;
import java.util.UUID;
public record PictureDto(
UUID id,
ZonedDateTime publishedAt
) {
public PictureDto(Picture picture) {
this(picture.id(), picture.publishedAt());
}
}

View File

@@ -0,0 +1,94 @@
package org.codiki.exposition.publication;
import org.codiki.application.publication.PublicationUseCases;
import org.codiki.domain.publication.exception.NoPublicationSearchResultException;
import org.codiki.domain.publication.exception.PublicationNotFoundException;
import org.codiki.domain.publication.model.Publication;
import org.codiki.domain.publication.model.PublicationEditionRequest;
import org.codiki.exposition.publication.model.PreviewContentRequest;
import org.codiki.exposition.publication.model.PreviewContentResponse;
import org.codiki.exposition.publication.model.PublicationDto;
import org.codiki.exposition.publication.model.PublicationEditionRequestDto;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
import static org.springframework.http.HttpStatus.CREATED;
import static org.springframework.http.HttpStatus.NO_CONTENT;
import static org.springframework.util.ObjectUtils.isEmpty;
@RestController
@RequestMapping("/api/publications")
public class PublicationController {
private final PublicationUseCases publicationUseCases;
public PublicationController(PublicationUseCases publicationUseCases) {
this.publicationUseCases = publicationUseCases;
}
@GetMapping("/{publicationId}")
public PublicationDto getById(@PathVariable("publicationId") UUID publicationId) {
return publicationUseCases.findById(publicationId)
.map(PublicationDto::new)
.orElseThrow(() -> new PublicationNotFoundException(publicationId));
}
@PostMapping
@ResponseStatus(CREATED)
public PublicationDto createPublication(@RequestBody PublicationEditionRequestDto requestDto) {
PublicationEditionRequest request = requestDto.toDomain();
Publication newPublication = publicationUseCases.createPublication(request);
return new PublicationDto(newPublication);
}
@PutMapping("/{publicationId}")
public PublicationDto updatePublication(
@PathVariable("publicationId") UUID publicationId,
@RequestBody PublicationEditionRequestDto requestDto
) {
PublicationEditionRequest request = requestDto.toDomain();
Publication updatedPublication = publicationUseCases.updatePublication(publicationId, request);
return new PublicationDto(updatedPublication);
}
@DeleteMapping("/{publicationId}")
@ResponseStatus(NO_CONTENT)
public void deletePublication(@PathVariable("publicationId") UUID publicationId) {
publicationUseCases.deletePublication(publicationId);
}
@GetMapping
public List<PublicationDto> searchPublications(@RequestParam("query") String searchQuery) {
List<PublicationDto> publications = publicationUseCases.searchPublications(searchQuery)
.stream()
.map(PublicationDto::new)
.toList();
if (isEmpty(publications)) {
throw new NoPublicationSearchResultException();
}
return publications;
}
@GetMapping("/latest")
public List<PublicationDto> getLatestPublications() {
List<PublicationDto> publications = publicationUseCases.getLatest()
.stream()
.map(PublicationDto::new)
.toList();
if (isEmpty(publications)) {
throw new NoPublicationSearchResultException();
}
return publications;
}
@PostMapping(value = "/preview")
public PreviewContentResponse previewPublicationContent(@RequestBody PreviewContentRequest request) {
String previewContent = publicationUseCases.previewContent(request.text());
return new PreviewContentResponse(previewContent);
}
}

View File

@@ -13,7 +13,7 @@ public record AuthorDto(
this(
author.id(),
author.name(),
author.image()
author.photoId()
);
}
}

View File

@@ -0,0 +1,5 @@
package org.codiki.exposition.publication.model;
public record PreviewContentRequest(
String text
) {}

View File

@@ -0,0 +1,5 @@
package org.codiki.exposition.publication.model;
public record PreviewContentResponse(
String text
) {}

View File

@@ -1,22 +1,21 @@
package org.codiki.exposition.publication;
package org.codiki.exposition.publication.model;
import org.codiki.domain.publication.model.Publication;
import java.time.ZonedDateTime;
import java.util.UUID;
import org.codiki.domain.publication.model.Publication;
import org.codiki.exposition.category.model.CategoryDto;
import org.codiki.exposition.publication.model.AuthorDto;
public record PublicationDto(
UUID id,
String key,
String title,
String text,
String parsedText,
String description,
String image,
ZonedDateTime creationDate,
AuthorDto author,
CategoryDto category
UUID illustrationId,
UUID categoryId,
AuthorDto author
) {
public PublicationDto(Publication publication) {
this(
@@ -24,11 +23,12 @@ public record PublicationDto(
publication.key(),
publication.title(),
publication.text(),
publication.parsedText(),
publication.description(),
publication.image(),
publication.creationDate(),
new AuthorDto(publication.author()),
new CategoryDto(publication.category())
publication.illustrationId(),
publication.categoryId(),
new AuthorDto(publication.author())
);
}
}

View File

@@ -0,0 +1,17 @@
package org.codiki.exposition.publication.model;
import java.util.UUID;
import org.codiki.domain.publication.model.PublicationEditionRequest;
public record PublicationEditionRequestDto(
String title,
String text,
String description,
UUID illustrationId,
UUID categoryId
) {
public PublicationEditionRequest toDomain() {
return new PublicationEditionRequest(title, text, description, illustrationId, categoryId);
}
}

View File

@@ -2,6 +2,7 @@ package org.codiki.exposition.user;
import java.util.List;
import static org.springframework.http.HttpStatus.CREATED;
import org.codiki.application.security.annotation.AllowedToAdmins;
import org.codiki.application.security.annotation.AllowedToAnonymous;
import org.codiki.application.user.UserUseCases;
@@ -10,10 +11,12 @@ import org.codiki.domain.user.model.UserAuthenticationData;
import org.codiki.exposition.user.model.LoginRequest;
import org.codiki.exposition.user.model.LoginResponse;
import org.codiki.exposition.user.model.RefreshTokenRequest;
import org.codiki.exposition.user.model.SignInRequestDto;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -28,7 +31,7 @@ public class UserController {
@PostMapping("/login")
@AllowedToAnonymous
public LoginResponse login(@RequestBody LoginRequest request) {
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.id(), request.password());
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.email(), request.password());
return new LoginResponse(userAuthenticationData);
}
@@ -43,4 +46,10 @@ public class UserController {
UserAuthenticationData userAuthenticationData = userUseCases.authenticate(request.refreshTokenValue());
return new LoginResponse(userAuthenticationData);
}
@PostMapping
@ResponseStatus(CREATED)
public void signIn(@RequestBody SignInRequestDto request) {
userUseCases.createUser(request.pseudo(), request.email(), request.password());
}
}

View File

@@ -1,8 +1,6 @@
package org.codiki.exposition.user.model;
import java.util.UUID;
public record LoginRequest(
UUID id,
String email,
String password
) {}

View File

@@ -0,0 +1,8 @@
package org.codiki.exposition.user.model;
public record SignInRequestDto(
String pseudo,
String email,
String password
) {
}

View File

@@ -37,5 +37,24 @@
<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

@@ -1,13 +1,24 @@
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;
@@ -25,12 +36,18 @@ public class CategoryEntity {
private UUID id;
@Column(nullable = false)
private String name;
// List<Category> subCategories
@OneToMany
@JoinColumn(name = "parent_category_id")
private Set<CategoryEntity> subCategories;
public CategoryEntity(Category category) {
this(
category.id(),
category.name()
category.name(),
category.subCategories()
.stream()
.map(CategoryEntity::new)
.collect(toSet())
);
}
@@ -38,7 +55,9 @@ public class CategoryEntity {
return new Category(
id,
name,
emptyList()
subCategories.stream()
.map(CategoryEntity::toDomain)
.toList()
);
}
}

View File

@@ -0,0 +1,23 @@
package org.codiki.infrastructure.category.repository;
import java.util.List;
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);
@Query("SELECT c FROM CategoryEntity c LEFT JOIN FETCH c.subCategories")
List<CategoryEntity> findAll();
}

View File

@@ -0,0 +1,78 @@
package org.codiki.infrastructure.picture;
import java.io.File;
import java.util.List;
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);
}
@Override
public List<Picture> findAllByPublisherId(UUID id) {
return repository.findAllByPublisherId(id)
.stream()
.map(PictureEntity::toDomain)
.toList();
}
}

View File

@@ -0,0 +1,40 @@
package org.codiki.infrastructure.picture.model;
import java.time.ZonedDateTime;
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;
@Column(nullable = false)
private ZonedDateTime publishedAt;
public PictureEntity(Picture picture) {
id = picture.id();
publisherId = picture.publisherId();
publishedAt = picture.publishedAt();
}
public Picture toDomain() {
return new Picture(id, publisherId, publishedAt, null);
}
}

View File

@@ -0,0 +1,12 @@
package org.codiki.infrastructure.picture.repository;
import java.util.List;
import java.util.UUID;
import org.codiki.domain.picture.model.Picture;
import org.codiki.infrastructure.picture.model.PictureEntity;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PictureRepository extends JpaRepository<PictureEntity, UUID> {
List<PictureEntity> findAllByPublisherId(UUID id);
}

View File

@@ -0,0 +1,69 @@
package org.codiki.infrastructure.publication;
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.PublicationSearchJpaCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchResult;
import org.codiki.infrastructure.publication.repository.PublicationRepository;
import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static java.util.Collections.reverseOrder;
import static java.util.Comparator.comparingInt;
@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<PublicationSearchJpaCriterion> 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();
}
@Override
public List<Publication> getLatest() {
return repository.getLatest(Limit.of(10))
.stream()
.map(PublicationEntity::toDomain)
.toList();
}
}

View File

@@ -0,0 +1,50 @@
package org.codiki.infrastructure.publication;
import java.util.LinkedList;
import java.util.List;
import org.codiki.domain.publication.exception.BadPublicationSearchCriterionException;
import org.codiki.domain.publication.model.search.PublicationSearchCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaCriterion;
import org.codiki.infrastructure.publication.model.PublicationSearchJpaField;
import org.springframework.stereotype.Component;
@Component
public class PublicationSearchCriteriaJpaAdapter {
public List<PublicationSearchJpaCriterion> 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.stream()
.map(this::mapToJpaCriterion)
.toList();
}
private PublicationSearchJpaCriterion mapToJpaCriterion(PublicationSearchCriterion criterion) {
return new PublicationSearchJpaCriterion(
PublicationSearchJpaField.fromDomain(criterion.searchField())
.orElseThrow(() -> new BadPublicationSearchCriterionException(
String.format("Unknown field research criterion: %s", criterion.searchField()))
),
criterion.searchType(),
criterion.value()
);
}
}

View File

@@ -23,14 +23,18 @@ public class AuthorEntity {
@Id
private UUID id;
@Column(nullable = false)
private String name;
private String image;
private String pseudo;
private String photoId;
public AuthorEntity(Author author) {
this(
author.id(),
author.name(),
author.image()
author.photoId()
);
}
public Author toDomain() {
return new Author(id, pseudo, photoId);
}
}

Some files were not shown because too many files have changed in this diff Show More