api issue
This commit is contained in:
+9
-1
@@ -1,5 +1,13 @@
|
|||||||
|
FROM eclipse-temurin:25-jdk-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY gradlew settings.gradle build.gradle ./
|
||||||
|
COPY gradle ./gradle
|
||||||
|
RUN ./gradlew dependencies --no-daemon -q
|
||||||
|
COPY src ./src
|
||||||
|
RUN ./gradlew build -x test --no-daemon
|
||||||
|
|
||||||
FROM eclipse-temurin:25-jre-alpine
|
FROM eclipse-temurin:25-jre-alpine
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY build/libs/*.jar app.jar
|
COPY --from=build /app/build/libs/*.jar app.jar
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Bonsai API
|
||||||
|
|
||||||
|
API REST de gestion d'issues (type Jira léger), construite avec Spring Boot 3.5 / Java 25 en Clean Architecture.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prérequis
|
||||||
|
|
||||||
|
| Outil | Version minimale |
|
||||||
|
|---|---|
|
||||||
|
| Java (JDK) | 25 |
|
||||||
|
| Docker + Docker Compose | 24+ |
|
||||||
|
| Gradle (wrapper inclus) | 8+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage en développement
|
||||||
|
|
||||||
|
### 1. Cloner le dépôt
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.goutailler-olivier.com/<org>/bonsai-api.git
|
||||||
|
cd bonsai-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Démarrer la base de données
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up db -d
|
||||||
|
```
|
||||||
|
|
||||||
|
PostgreSQL démarre sur `localhost:5432`, base `bonsai`, utilisateur `bonsai`, mot de passe `bonsai`.
|
||||||
|
|
||||||
|
### 3. Lancer l'API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
Flyway applique automatiquement la migration `V1__init.sql` au premier démarrage.
|
||||||
|
L'API est disponible sur `http://localhost:8080`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Démarrage complet via Docker Compose
|
||||||
|
|
||||||
|
Pour lancer l'API et la base de données en une seule commande :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour arrêter et supprimer les conteneurs :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
Pour supprimer également le volume PostgreSQL (réinitialise la base) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables d'environnement
|
||||||
|
|
||||||
|
Les valeurs par défaut conviennent pour le développement local.
|
||||||
|
En production, surcharger les variables suivantes :
|
||||||
|
|
||||||
|
| Variable | Défaut | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `DATASOURCE_URL` | `jdbc:postgresql://localhost:5432/bonsai` | URL JDBC PostgreSQL |
|
||||||
|
| `DATASOURCE_USERNAME` | `bonsai` | Utilisateur base de données |
|
||||||
|
| `DATASOURCE_PASSWORD` | `bonsai` | Mot de passe base de données |
|
||||||
|
| `KEYCLOAK_JWKS_URI` | `https://auth.goutailler-olivier.com/realms/bonsai/protocol/openid-connect/certs` | Endpoint JWKS Keycloak |
|
||||||
|
| `CORS_ALLOWED_ORIGIN_PROD` | `https://bonsai.goutailler-olivier.com` | Origine CORS de production |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
Toutes les routes nécessitent un token JWT Bearer valide, émis par Keycloak :
|
||||||
|
|
||||||
|
- **Realm** : `bonsai`
|
||||||
|
- **Client** : `bonsai-webapp`
|
||||||
|
- **Issuer** : `https://auth.goutailler-olivier.com/realms/bonsai`
|
||||||
|
|
||||||
|
```http
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Méthode | Route | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/issues` | Liste toutes les issues |
|
||||||
|
| `POST` | `/issues` | Crée une issue |
|
||||||
|
| `PUT` | `/issues/{id}` | Remplace une issue complète |
|
||||||
|
| `DELETE` | `/issues/{id}` | Supprime une issue (204) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lancer les tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew test
|
||||||
|
```
|
||||||
|
|
||||||
|
Le rapport HTML est généré dans `build/reports/tests/test/index.html`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/java/fr/bonsai/api/
|
||||||
|
├── domain/model/ # Entités métier (sans dépendance Spring)
|
||||||
|
├── application/
|
||||||
|
│ ├── port/in/ # Interfaces des use cases
|
||||||
|
│ ├── port/out/ # Interface du repository
|
||||||
|
│ └── usecase/ # Logique métier (IssueService)
|
||||||
|
├── adapter/
|
||||||
|
│ ├── in/web/ # Controllers REST et DTOs
|
||||||
|
│ └── out/persistence/ # Entités JPA et adaptateur repository
|
||||||
|
└── config/ # Configuration Spring (Security, CORS, Beans)
|
||||||
|
```
|
||||||
+8
-1
@@ -19,9 +19,16 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-security'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
implementation 'org.flywaydb:flyway-core'
|
||||||
|
implementation 'org.flywaydb:flyway-database-postgresql'
|
||||||
|
runtimeOnly 'org.postgresql:postgresql'
|
||||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testImplementation 'org.springframework.security:spring-security-test'
|
||||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: bonsai
|
||||||
|
POSTGRES_USER: bonsai
|
||||||
|
POSTGRES_PASSWORD: bonsai
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U bonsai"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
DATASOURCE_URL: jdbc:postgresql://db:5432/bonsai
|
||||||
|
DATASOURCE_USERNAME: bonsai
|
||||||
|
DATASOURCE_PASSWORD: bonsai
|
||||||
|
KEYCLOAK_JWKS_URI: https://auth.goutailler-olivier.com/realms/bonsai/protocol/openid-connect/certs
|
||||||
|
CORS_ALLOWED_ORIGIN_PROD: https://bonsai.goutailler-olivier.com
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
package fr.bonsai.api.adapter.in.web;
|
|
||||||
|
|
||||||
import fr.bonsai.api.adapter.in.web.dto.BonsaiRequest;
|
|
||||||
import fr.bonsai.api.adapter.in.web.dto.BonsaiResponse;
|
|
||||||
import fr.bonsai.api.application.port.in.CreateBonsaiUseCase;
|
|
||||||
import fr.bonsai.api.application.port.in.GetBonsaiUseCase;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/bonsais")
|
|
||||||
public class BonsaiController {
|
|
||||||
|
|
||||||
private final GetBonsaiUseCase getBonsaiUseCase;
|
|
||||||
private final CreateBonsaiUseCase createBonsaiUseCase;
|
|
||||||
|
|
||||||
public BonsaiController(GetBonsaiUseCase getBonsaiUseCase, CreateBonsaiUseCase createBonsaiUseCase) {
|
|
||||||
this.getBonsaiUseCase = getBonsaiUseCase;
|
|
||||||
this.createBonsaiUseCase = createBonsaiUseCase;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
public List<BonsaiResponse> getAll() {
|
|
||||||
return getBonsaiUseCase.getAll().stream()
|
|
||||||
.map(BonsaiResponse::from)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
|
||||||
public BonsaiResponse getById(@PathVariable UUID id) {
|
|
||||||
return BonsaiResponse.from(getBonsaiUseCase.getById(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostMapping
|
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
|
||||||
public BonsaiResponse create(@RequestBody BonsaiRequest request) {
|
|
||||||
var command = new CreateBonsaiUseCase.Command(request.name(), request.species(), request.ageYears());
|
|
||||||
return BonsaiResponse.from(createBonsaiUseCase.create(command));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package fr.bonsai.api.adapter.in.web;
|
||||||
|
|
||||||
|
import fr.bonsai.api.adapter.in.web.dto.IssueRequest;
|
||||||
|
import fr.bonsai.api.adapter.in.web.dto.IssueResponse;
|
||||||
|
import fr.bonsai.api.application.port.in.*;
|
||||||
|
import fr.bonsai.api.domain.model.*;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/issues")
|
||||||
|
public class IssueController {
|
||||||
|
|
||||||
|
private final GetIssuesUseCase getIssuesUseCase;
|
||||||
|
private final CreateIssueUseCase createIssueUseCase;
|
||||||
|
private final UpdateIssueUseCase updateIssueUseCase;
|
||||||
|
private final DeleteIssueUseCase deleteIssueUseCase;
|
||||||
|
|
||||||
|
public IssueController(GetIssuesUseCase getIssuesUseCase,
|
||||||
|
CreateIssueUseCase createIssueUseCase,
|
||||||
|
UpdateIssueUseCase updateIssueUseCase,
|
||||||
|
DeleteIssueUseCase deleteIssueUseCase) {
|
||||||
|
this.getIssuesUseCase = getIssuesUseCase;
|
||||||
|
this.createIssueUseCase = createIssueUseCase;
|
||||||
|
this.updateIssueUseCase = updateIssueUseCase;
|
||||||
|
this.deleteIssueUseCase = deleteIssueUseCase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<IssueResponse> getAll() {
|
||||||
|
return getIssuesUseCase.getAll().stream().map(IssueResponse::from).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public IssueResponse create(@RequestBody IssueRequest request) {
|
||||||
|
var command = new CreateIssueUseCase.Command(
|
||||||
|
IssueType.fromValue(request.type()),
|
||||||
|
request.assignee(),
|
||||||
|
request.epic(),
|
||||||
|
request.name(),
|
||||||
|
parseDate(request.dueDate()),
|
||||||
|
request.description(),
|
||||||
|
request.estimatedTime(),
|
||||||
|
nullSafe(request.dependsOnIds()),
|
||||||
|
toComments(request),
|
||||||
|
Priority.fromValue(request.priority()),
|
||||||
|
IssueStatus.fromValue(request.status()),
|
||||||
|
request.progress()
|
||||||
|
);
|
||||||
|
return IssueResponse.from(createIssueUseCase.create(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public IssueResponse update(@PathVariable Long id, @RequestBody IssueRequest request) {
|
||||||
|
var command = new UpdateIssueUseCase.Command(
|
||||||
|
id,
|
||||||
|
IssueType.fromValue(request.type()),
|
||||||
|
request.assignee(),
|
||||||
|
request.epic(),
|
||||||
|
request.name(),
|
||||||
|
parseDate(request.dueDate()),
|
||||||
|
request.description(),
|
||||||
|
request.estimatedTime(),
|
||||||
|
nullSafe(request.dependsOnIds()),
|
||||||
|
toComments(request),
|
||||||
|
Priority.fromValue(request.priority()),
|
||||||
|
IssueStatus.fromValue(request.status()),
|
||||||
|
request.progress()
|
||||||
|
);
|
||||||
|
return IssueResponse.from(updateIssueUseCase.update(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void delete(@PathVariable Long id) {
|
||||||
|
deleteIssueUseCase.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private LocalDate parseDate(String date) {
|
||||||
|
return date != null && !date.isBlank() ? LocalDate.parse(date) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Long> nullSafe(List<Long> list) {
|
||||||
|
return list != null ? list : List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Comment> toComments(IssueRequest request) {
|
||||||
|
if (request.comments() == null) return List.of();
|
||||||
|
return request.comments().stream().map(dto -> dto.toDomain()).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package fr.bonsai.api.adapter.in.web.dto;
|
|
||||||
|
|
||||||
public record BonsaiRequest(String name, String species, int ageYears) {}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package fr.bonsai.api.adapter.in.web.dto;
|
|
||||||
|
|
||||||
import fr.bonsai.api.domain.model.Bonsai;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public record BonsaiResponse(UUID id, String name, String species, int ageYears) {
|
|
||||||
|
|
||||||
public static BonsaiResponse from(Bonsai bonsai) {
|
|
||||||
return new BonsaiResponse(bonsai.getId(), bonsai.getName(), bonsai.getSpecies(), bonsai.getAgeYears());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package fr.bonsai.api.adapter.in.web.dto;
|
||||||
|
|
||||||
|
import fr.bonsai.api.domain.model.Comment;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record CommentDto(Long id, String text, String createdAt, String updatedAt) {
|
||||||
|
|
||||||
|
public static CommentDto from(Comment comment) {
|
||||||
|
return new CommentDto(
|
||||||
|
comment.getId(),
|
||||||
|
comment.getText(),
|
||||||
|
comment.getCreatedAt() != null ? comment.getCreatedAt().toString() : null,
|
||||||
|
comment.getUpdatedAt() != null ? comment.getUpdatedAt().toString() : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Comment toDomain() {
|
||||||
|
return new Comment(
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
createdAt != null ? Instant.parse(createdAt) : Instant.now(),
|
||||||
|
updatedAt != null ? Instant.parse(updatedAt) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package fr.bonsai.api.adapter.in.web.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record IssueRequest(
|
||||||
|
String type,
|
||||||
|
String assignee,
|
||||||
|
String epic,
|
||||||
|
String name,
|
||||||
|
String dueDate,
|
||||||
|
String description,
|
||||||
|
Double estimatedTime,
|
||||||
|
List<Long> dependsOnIds,
|
||||||
|
List<CommentDto> comments,
|
||||||
|
String priority,
|
||||||
|
String status,
|
||||||
|
int progress
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package fr.bonsai.api.adapter.in.web.dto;
|
||||||
|
|
||||||
|
import fr.bonsai.api.domain.model.Issue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record IssueResponse(
|
||||||
|
Long id,
|
||||||
|
String type,
|
||||||
|
String assignee,
|
||||||
|
String epic,
|
||||||
|
String name,
|
||||||
|
String dueDate,
|
||||||
|
String description,
|
||||||
|
Double estimatedTime,
|
||||||
|
List<Long> dependsOnIds,
|
||||||
|
List<CommentDto> comments,
|
||||||
|
String priority,
|
||||||
|
String status,
|
||||||
|
int progress
|
||||||
|
) {
|
||||||
|
public static IssueResponse from(Issue issue) {
|
||||||
|
return new IssueResponse(
|
||||||
|
issue.getId(),
|
||||||
|
issue.getType().getValue(),
|
||||||
|
issue.getAssignee(),
|
||||||
|
issue.getEpic(),
|
||||||
|
issue.getName(),
|
||||||
|
issue.getDueDate() != null ? issue.getDueDate().toString() : null,
|
||||||
|
issue.getDescription(),
|
||||||
|
issue.getEstimatedTime(),
|
||||||
|
issue.getDependsOnIds(),
|
||||||
|
issue.getComments().stream().map(CommentDto::from).toList(),
|
||||||
|
issue.getPriority().getValue(),
|
||||||
|
issue.getStatus().getValue(),
|
||||||
|
issue.getProgress()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package fr.bonsai.api.adapter.in.web.exception;
|
||||||
|
|
||||||
|
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 java.util.NoSuchElementException;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(NoSuchElementException.class)
|
||||||
|
public ProblemDetail handleNotFound(NoSuchElementException ex) {
|
||||||
|
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(IllegalArgumentException.class)
|
||||||
|
public ProblemDetail handleBadRequest(IllegalArgumentException ex) {
|
||||||
|
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package fr.bonsai.api.adapter.out.persistence;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "comments")
|
||||||
|
public class CommentJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "issue_id", nullable = false)
|
||||||
|
private IssueJpaEntity issue;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT", nullable = false)
|
||||||
|
private String text;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public IssueJpaEntity getIssue() { return issue; }
|
||||||
|
public void setIssue(IssueJpaEntity issue) { this.issue = issue; }
|
||||||
|
public String getText() { return text; }
|
||||||
|
public void setText(String text) { this.text = text; }
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||||
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
|
public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; }
|
||||||
|
}
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package fr.bonsai.api.adapter.out.persistence;
|
|
||||||
|
|
||||||
import fr.bonsai.api.application.port.out.BonsaiRepository;
|
|
||||||
import fr.bonsai.api.domain.model.Bonsai;
|
|
||||||
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
|
|
||||||
public class InMemoryBonsaiRepository implements BonsaiRepository {
|
|
||||||
|
|
||||||
private final Map<UUID, Bonsai> store = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<Bonsai> findAll() {
|
|
||||||
return List.copyOf(store.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Optional<Bonsai> findById(UUID id) {
|
|
||||||
return Optional.ofNullable(store.get(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Bonsai save(Bonsai bonsai) {
|
|
||||||
store.put(bonsai.getId(), bonsai);
|
|
||||||
return bonsai;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package fr.bonsai.api.adapter.out.persistence;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "issues")
|
||||||
|
public class IssueJpaEntity {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 50)
|
||||||
|
private String type;
|
||||||
|
|
||||||
|
private String assignee;
|
||||||
|
private String epic;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private LocalDate dueDate;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private Double estimatedTime;
|
||||||
|
|
||||||
|
@ElementCollection
|
||||||
|
@CollectionTable(name = "issue_depends_on", joinColumns = @JoinColumn(name = "issue_id"))
|
||||||
|
@Column(name = "depends_on_id")
|
||||||
|
private List<Long> dependsOnIds = new ArrayList<>();
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "issue", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
@OrderBy("createdAt ASC")
|
||||||
|
private List<CommentJpaEntity> comments = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 50)
|
||||||
|
private String priority;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 50)
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private int progress;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
public String getType() { return type; }
|
||||||
|
public void setType(String type) { this.type = type; }
|
||||||
|
public String getAssignee() { return assignee; }
|
||||||
|
public void setAssignee(String assignee) { this.assignee = assignee; }
|
||||||
|
public String getEpic() { return epic; }
|
||||||
|
public void setEpic(String epic) { this.epic = epic; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public LocalDate getDueDate() { return dueDate; }
|
||||||
|
public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
public Double getEstimatedTime() { return estimatedTime; }
|
||||||
|
public void setEstimatedTime(Double estimatedTime) { this.estimatedTime = estimatedTime; }
|
||||||
|
public List<Long> getDependsOnIds() { return dependsOnIds; }
|
||||||
|
public void setDependsOnIds(List<Long> dependsOnIds) { this.dependsOnIds = dependsOnIds; }
|
||||||
|
public List<CommentJpaEntity> getComments() { return comments; }
|
||||||
|
public String getPriority() { return priority; }
|
||||||
|
public void setPriority(String priority) { this.priority = priority; }
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
public int getProgress() { return progress; }
|
||||||
|
public void setProgress(int progress) { this.progress = progress; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package fr.bonsai.api.adapter.out.persistence;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface IssueJpaRepository extends JpaRepository<IssueJpaEntity, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package fr.bonsai.api.adapter.out.persistence;
|
||||||
|
|
||||||
|
import fr.bonsai.api.domain.model.*;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class IssueMapper {
|
||||||
|
|
||||||
|
public static Issue toDomain(IssueJpaEntity entity) {
|
||||||
|
List<Comment> comments = entity.getComments().stream()
|
||||||
|
.map(c -> new Comment(c.getId(), c.getText(), c.getCreatedAt(), c.getUpdatedAt()))
|
||||||
|
.toList();
|
||||||
|
return new Issue(
|
||||||
|
entity.getId(),
|
||||||
|
IssueType.fromValue(entity.getType()),
|
||||||
|
entity.getAssignee(),
|
||||||
|
entity.getEpic(),
|
||||||
|
entity.getName(),
|
||||||
|
entity.getDueDate(),
|
||||||
|
entity.getDescription(),
|
||||||
|
entity.getEstimatedTime(),
|
||||||
|
new ArrayList<>(entity.getDependsOnIds()),
|
||||||
|
comments,
|
||||||
|
Priority.fromValue(entity.getPriority()),
|
||||||
|
IssueStatus.fromValue(entity.getStatus()),
|
||||||
|
entity.getProgress()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IssueJpaEntity toJpa(Issue issue) {
|
||||||
|
IssueJpaEntity entity = new IssueJpaEntity();
|
||||||
|
if (issue.getId() != null) entity.setId(issue.getId());
|
||||||
|
entity.setType(issue.getType().getValue());
|
||||||
|
entity.setAssignee(issue.getAssignee());
|
||||||
|
entity.setEpic(issue.getEpic());
|
||||||
|
entity.setName(issue.getName());
|
||||||
|
entity.setDueDate(issue.getDueDate());
|
||||||
|
entity.setDescription(issue.getDescription());
|
||||||
|
entity.setEstimatedTime(issue.getEstimatedTime());
|
||||||
|
entity.setDependsOnIds(new ArrayList<>(issue.getDependsOnIds()));
|
||||||
|
entity.setPriority(issue.getPriority().getValue());
|
||||||
|
entity.setStatus(issue.getStatus().getValue());
|
||||||
|
entity.setProgress(issue.getProgress());
|
||||||
|
|
||||||
|
for (Comment c : issue.getComments()) {
|
||||||
|
CommentJpaEntity ce = new CommentJpaEntity();
|
||||||
|
ce.setIssue(entity);
|
||||||
|
ce.setText(c.getText());
|
||||||
|
ce.setCreatedAt(c.getCreatedAt());
|
||||||
|
ce.setUpdatedAt(c.getUpdatedAt());
|
||||||
|
entity.getComments().add(ce);
|
||||||
|
}
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package fr.bonsai.api.adapter.out.persistence;
|
||||||
|
|
||||||
|
import fr.bonsai.api.application.port.out.IssueRepository;
|
||||||
|
import fr.bonsai.api.domain.model.Issue;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public class JpaIssueRepositoryAdapter implements IssueRepository {
|
||||||
|
|
||||||
|
private final IssueJpaRepository jpaRepository;
|
||||||
|
|
||||||
|
public JpaIssueRepositoryAdapter(IssueJpaRepository jpaRepository) {
|
||||||
|
this.jpaRepository = jpaRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<Issue> findAll() {
|
||||||
|
return jpaRepository.findAll().stream().map(IssueMapper::toDomain).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<Issue> findById(Long id) {
|
||||||
|
return jpaRepository.findById(id).map(IssueMapper::toDomain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Issue save(Issue issue) {
|
||||||
|
if (issue.getId() != null) {
|
||||||
|
// Update: work on the managed entity so orphanRemoval triggers correctly
|
||||||
|
IssueJpaEntity existing = jpaRepository.findById(issue.getId())
|
||||||
|
.orElseThrow(() -> new java.util.NoSuchElementException("Issue not found: " + issue.getId()));
|
||||||
|
mergeInto(existing, issue);
|
||||||
|
return IssueMapper.toDomain(jpaRepository.save(existing));
|
||||||
|
}
|
||||||
|
return IssueMapper.toDomain(jpaRepository.save(IssueMapper.toJpa(issue)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mergeInto(IssueJpaEntity target, Issue source) {
|
||||||
|
target.setType(source.getType().getValue());
|
||||||
|
target.setAssignee(source.getAssignee());
|
||||||
|
target.setEpic(source.getEpic());
|
||||||
|
target.setName(source.getName());
|
||||||
|
target.setDueDate(source.getDueDate());
|
||||||
|
target.setDescription(source.getDescription());
|
||||||
|
target.setEstimatedTime(source.getEstimatedTime());
|
||||||
|
target.setDependsOnIds(new java.util.ArrayList<>(source.getDependsOnIds()));
|
||||||
|
target.setPriority(source.getPriority().getValue());
|
||||||
|
target.setStatus(source.getStatus().getValue());
|
||||||
|
target.setProgress(source.getProgress());
|
||||||
|
|
||||||
|
target.getComments().clear();
|
||||||
|
for (fr.bonsai.api.domain.model.Comment c : source.getComments()) {
|
||||||
|
CommentJpaEntity ce = new CommentJpaEntity();
|
||||||
|
ce.setIssue(target);
|
||||||
|
ce.setText(c.getText());
|
||||||
|
ce.setCreatedAt(c.getCreatedAt());
|
||||||
|
ce.setUpdatedAt(c.getUpdatedAt());
|
||||||
|
target.getComments().add(ce);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteById(Long id) {
|
||||||
|
jpaRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean existsById(Long id) {
|
||||||
|
return jpaRepository.existsById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package fr.bonsai.api.application.port.in;
|
|
||||||
|
|
||||||
import fr.bonsai.api.domain.model.Bonsai;
|
|
||||||
|
|
||||||
public interface CreateBonsaiUseCase {
|
|
||||||
|
|
||||||
record Command(String name, String species, int ageYears) {}
|
|
||||||
|
|
||||||
Bonsai create(Command command);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package fr.bonsai.api.application.port.in;
|
||||||
|
|
||||||
|
import fr.bonsai.api.domain.model.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface CreateIssueUseCase {
|
||||||
|
|
||||||
|
record Command(
|
||||||
|
IssueType type,
|
||||||
|
String assignee,
|
||||||
|
String epic,
|
||||||
|
String name,
|
||||||
|
LocalDate dueDate,
|
||||||
|
String description,
|
||||||
|
Double estimatedTime,
|
||||||
|
List<Long> dependsOnIds,
|
||||||
|
List<Comment> comments,
|
||||||
|
Priority priority,
|
||||||
|
IssueStatus status,
|
||||||
|
int progress
|
||||||
|
) {}
|
||||||
|
|
||||||
|
Issue create(Command command);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package fr.bonsai.api.application.port.in;
|
||||||
|
|
||||||
|
public interface DeleteIssueUseCase {
|
||||||
|
|
||||||
|
void delete(Long id);
|
||||||
|
}
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package fr.bonsai.api.application.port.in;
|
|
||||||
|
|
||||||
import fr.bonsai.api.domain.model.Bonsai;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface GetBonsaiUseCase {
|
|
||||||
|
|
||||||
List<Bonsai> getAll();
|
|
||||||
|
|
||||||
Bonsai getById(UUID id);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package fr.bonsai.api.application.port.in;
|
||||||
|
|
||||||
|
import fr.bonsai.api.domain.model.Issue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface GetIssuesUseCase {
|
||||||
|
|
||||||
|
List<Issue> getAll();
|
||||||
|
|
||||||
|
Issue getById(Long id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package fr.bonsai.api.application.port.in;
|
||||||
|
|
||||||
|
import fr.bonsai.api.domain.model.*;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface UpdateIssueUseCase {
|
||||||
|
|
||||||
|
record Command(
|
||||||
|
Long id,
|
||||||
|
IssueType type,
|
||||||
|
String assignee,
|
||||||
|
String epic,
|
||||||
|
String name,
|
||||||
|
LocalDate dueDate,
|
||||||
|
String description,
|
||||||
|
Double estimatedTime,
|
||||||
|
List<Long> dependsOnIds,
|
||||||
|
List<Comment> comments,
|
||||||
|
Priority priority,
|
||||||
|
IssueStatus status,
|
||||||
|
int progress
|
||||||
|
) {}
|
||||||
|
|
||||||
|
Issue update(Command command);
|
||||||
|
}
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package fr.bonsai.api.application.port.out;
|
|
||||||
|
|
||||||
import fr.bonsai.api.domain.model.Bonsai;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public interface BonsaiRepository {
|
|
||||||
|
|
||||||
List<Bonsai> findAll();
|
|
||||||
|
|
||||||
Optional<Bonsai> findById(UUID id);
|
|
||||||
|
|
||||||
Bonsai save(Bonsai bonsai);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package fr.bonsai.api.application.port.out;
|
||||||
|
|
||||||
|
import fr.bonsai.api.domain.model.Issue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface IssueRepository {
|
||||||
|
|
||||||
|
List<Issue> findAll();
|
||||||
|
|
||||||
|
Optional<Issue> findById(Long id);
|
||||||
|
|
||||||
|
Issue save(Issue issue);
|
||||||
|
|
||||||
|
void deleteById(Long id);
|
||||||
|
|
||||||
|
boolean existsById(Long id);
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
package fr.bonsai.api.application.usecase;
|
|
||||||
|
|
||||||
import fr.bonsai.api.application.port.in.CreateBonsaiUseCase;
|
|
||||||
import fr.bonsai.api.application.port.in.GetBonsaiUseCase;
|
|
||||||
import fr.bonsai.api.application.port.out.BonsaiRepository;
|
|
||||||
import fr.bonsai.api.domain.model.Bonsai;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.NoSuchElementException;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public class BonsaiService implements GetBonsaiUseCase, CreateBonsaiUseCase {
|
|
||||||
|
|
||||||
private final BonsaiRepository repository;
|
|
||||||
|
|
||||||
public BonsaiService(BonsaiRepository repository) {
|
|
||||||
this.repository = repository;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<Bonsai> getAll() {
|
|
||||||
return repository.findAll();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Bonsai getById(UUID id) {
|
|
||||||
return repository.findById(id)
|
|
||||||
.orElseThrow(() -> new NoSuchElementException("Bonsai not found: " + id));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Bonsai create(Command command) {
|
|
||||||
Bonsai bonsai = Bonsai.create(command.name(), command.species(), command.ageYears());
|
|
||||||
return repository.save(bonsai);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package fr.bonsai.api.application.usecase;
|
||||||
|
|
||||||
|
import fr.bonsai.api.application.port.in.*;
|
||||||
|
import fr.bonsai.api.application.port.out.IssueRepository;
|
||||||
|
import fr.bonsai.api.domain.model.Issue;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.NoSuchElementException;
|
||||||
|
|
||||||
|
public class IssueService implements GetIssuesUseCase, CreateIssueUseCase, UpdateIssueUseCase, DeleteIssueUseCase {
|
||||||
|
|
||||||
|
private final IssueRepository repository;
|
||||||
|
|
||||||
|
public IssueService(IssueRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Issue> getAll() {
|
||||||
|
return repository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Issue getById(Long id) {
|
||||||
|
return repository.findById(id)
|
||||||
|
.orElseThrow(() -> new NoSuchElementException("Issue not found: " + id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Issue create(CreateIssueUseCase.Command cmd) {
|
||||||
|
Issue issue = new Issue(
|
||||||
|
null, cmd.type(), cmd.assignee(), cmd.epic(), cmd.name(),
|
||||||
|
cmd.dueDate(), cmd.description(), cmd.estimatedTime(),
|
||||||
|
cmd.dependsOnIds(), cmd.comments(),
|
||||||
|
cmd.priority(), cmd.status(), cmd.progress()
|
||||||
|
);
|
||||||
|
return repository.save(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Issue update(UpdateIssueUseCase.Command cmd) {
|
||||||
|
if (!repository.existsById(cmd.id())) {
|
||||||
|
throw new NoSuchElementException("Issue not found: " + cmd.id());
|
||||||
|
}
|
||||||
|
Issue issue = new Issue(
|
||||||
|
cmd.id(), cmd.type(), cmd.assignee(), cmd.epic(), cmd.name(),
|
||||||
|
cmd.dueDate(), cmd.description(), cmd.estimatedTime(),
|
||||||
|
cmd.dependsOnIds(), cmd.comments(),
|
||||||
|
cmd.priority(), cmd.status(), cmd.progress()
|
||||||
|
);
|
||||||
|
return repository.save(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void delete(Long id) {
|
||||||
|
if (!repository.existsById(id)) {
|
||||||
|
throw new NoSuchElementException("Issue not found: " + id);
|
||||||
|
}
|
||||||
|
repository.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
package fr.bonsai.api.config;
|
package fr.bonsai.api.config;
|
||||||
|
|
||||||
import fr.bonsai.api.adapter.out.persistence.InMemoryBonsaiRepository;
|
import fr.bonsai.api.adapter.out.persistence.IssueJpaRepository;
|
||||||
import fr.bonsai.api.application.port.out.BonsaiRepository;
|
import fr.bonsai.api.adapter.out.persistence.JpaIssueRepositoryAdapter;
|
||||||
import fr.bonsai.api.application.usecase.BonsaiService;
|
import fr.bonsai.api.application.port.out.IssueRepository;
|
||||||
|
import fr.bonsai.api.application.usecase.IssueService;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
@@ -10,12 +11,12 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
public class BeanConfig {
|
public class BeanConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public BonsaiRepository bonsaiRepository() {
|
public IssueRepository issueRepository(IssueJpaRepository jpaRepository) {
|
||||||
return new InMemoryBonsaiRepository();
|
return new JpaIssueRepositoryAdapter(jpaRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public BonsaiService bonsaiService(BonsaiRepository repository) {
|
public IssueService issueService(IssueRepository issueRepository) {
|
||||||
return new BonsaiService(repository);
|
return new IssueService(issueRepository);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package fr.bonsai.api.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class CorsConfig {
|
||||||
|
|
||||||
|
@Value("${app.cors.allowed-origins}")
|
||||||
|
private List<String> allowedOrigins;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
config.setAllowedOrigins(allowedOrigins);
|
||||||
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
config.setAllowedHeaders(List.of("Content-Type", "Authorization"));
|
||||||
|
config.setAllowCredentials(false);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", config);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package fr.bonsai.api.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.config.Customizer;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.cors(Customizer.withDefaults())
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.sessionManagement(session ->
|
||||||
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.oauth2ResourceServer(oauth2 -> oauth2
|
||||||
|
.jwt(Customizer.withDefaults())
|
||||||
|
);
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
package fr.bonsai.api.domain.model;
|
|
||||||
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
public class Bonsai {
|
|
||||||
|
|
||||||
private final UUID id;
|
|
||||||
private String name;
|
|
||||||
private String species;
|
|
||||||
private int ageYears;
|
|
||||||
|
|
||||||
public Bonsai(UUID id, String name, String species, int ageYears) {
|
|
||||||
this.id = id;
|
|
||||||
this.name = name;
|
|
||||||
this.species = species;
|
|
||||||
this.ageYears = ageYears;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static Bonsai create(String name, String species, int ageYears) {
|
|
||||||
return new Bonsai(UUID.randomUUID(), name, species, ageYears);
|
|
||||||
}
|
|
||||||
|
|
||||||
public UUID getId() { return id; }
|
|
||||||
public String getName() { return name; }
|
|
||||||
public String getSpecies() { return species; }
|
|
||||||
public int getAgeYears() { return ageYears; }
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package fr.bonsai.api.domain.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public class Comment {
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final String text;
|
||||||
|
private final Instant createdAt;
|
||||||
|
private final Instant updatedAt;
|
||||||
|
|
||||||
|
public Comment(Long id, String text, Instant createdAt, Instant updatedAt) {
|
||||||
|
this.id = id;
|
||||||
|
this.text = text;
|
||||||
|
this.createdAt = createdAt;
|
||||||
|
this.updatedAt = updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getText() { return text; }
|
||||||
|
public Instant getCreatedAt() { return createdAt; }
|
||||||
|
public Instant getUpdatedAt() { return updatedAt; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package fr.bonsai.api.domain.model;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class Issue {
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final IssueType type;
|
||||||
|
private final String assignee;
|
||||||
|
private final String epic;
|
||||||
|
private final String name;
|
||||||
|
private final LocalDate dueDate;
|
||||||
|
private final String description;
|
||||||
|
private final Double estimatedTime;
|
||||||
|
private final List<Long> dependsOnIds;
|
||||||
|
private final List<Comment> comments;
|
||||||
|
private final Priority priority;
|
||||||
|
private final IssueStatus status;
|
||||||
|
private final int progress;
|
||||||
|
|
||||||
|
public Issue(Long id, IssueType type, String assignee, String epic, String name,
|
||||||
|
LocalDate dueDate, String description, Double estimatedTime,
|
||||||
|
List<Long> dependsOnIds, List<Comment> comments,
|
||||||
|
Priority priority, IssueStatus status, int progress) {
|
||||||
|
this.id = id;
|
||||||
|
this.type = type;
|
||||||
|
this.assignee = assignee;
|
||||||
|
this.epic = epic;
|
||||||
|
this.name = name;
|
||||||
|
this.dueDate = dueDate;
|
||||||
|
this.description = description;
|
||||||
|
this.estimatedTime = estimatedTime;
|
||||||
|
this.dependsOnIds = dependsOnIds;
|
||||||
|
this.comments = comments;
|
||||||
|
this.priority = priority;
|
||||||
|
this.status = status;
|
||||||
|
this.progress = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public IssueType getType() { return type; }
|
||||||
|
public String getAssignee() { return assignee; }
|
||||||
|
public String getEpic() { return epic; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public LocalDate getDueDate() { return dueDate; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public Double getEstimatedTime() { return estimatedTime; }
|
||||||
|
public List<Long> getDependsOnIds() { return dependsOnIds; }
|
||||||
|
public List<Comment> getComments() { return comments; }
|
||||||
|
public Priority getPriority() { return priority; }
|
||||||
|
public IssueStatus getStatus() { return status; }
|
||||||
|
public int getProgress() { return progress; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package fr.bonsai.api.domain.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public enum IssueStatus {
|
||||||
|
DRAFT("draft"),
|
||||||
|
TODO("todo"),
|
||||||
|
IN_PROGRESS("in-progress"),
|
||||||
|
DONE("done");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
IssueStatus(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonValue
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonCreator
|
||||||
|
public static IssueStatus fromValue(String value) {
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.filter(s -> s.value.equals(value))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Unknown IssueStatus: " + value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package fr.bonsai.api.domain.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public enum IssueType {
|
||||||
|
EPIC("Epic"),
|
||||||
|
BUG("Bug"),
|
||||||
|
STUDY("Study"),
|
||||||
|
STORY("Story"),
|
||||||
|
TASK("Task"),
|
||||||
|
TECHNICAL_STORY("Technical Story");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
IssueType(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonValue
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonCreator
|
||||||
|
public static IssueType fromValue(String value) {
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.filter(t -> t.value.equals(value))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Unknown IssueType: " + value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package fr.bonsai.api.domain.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
public enum Priority {
|
||||||
|
BASSE("Basse"),
|
||||||
|
MOYENNE("Moyenne"),
|
||||||
|
HAUTE("Haute");
|
||||||
|
|
||||||
|
private final String value;
|
||||||
|
|
||||||
|
Priority(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonValue
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonCreator
|
||||||
|
public static Priority fromValue(String value) {
|
||||||
|
return Arrays.stream(values())
|
||||||
|
.filter(p -> p.value.equals(value))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Unknown Priority: " + value));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
spring.application.name=bonsai-api
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: ${DATASOURCE_URL:jdbc:postgresql://localhost:5432/bonsai}
|
||||||
|
username: ${DATASOURCE_USERNAME:bonsai}
|
||||||
|
password: ${DATASOURCE_PASSWORD:bonsai}
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
open-in-view: false
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
security:
|
||||||
|
oauth2:
|
||||||
|
resourceserver:
|
||||||
|
jwt:
|
||||||
|
jwk-set-uri: ${KEYCLOAK_JWKS_URI:https://auth.goutailler-olivier.com/realms/bonsai/protocol/openid-connect/certs}
|
||||||
|
|
||||||
|
app:
|
||||||
|
cors:
|
||||||
|
allowed-origins:
|
||||||
|
- http://localhost:4200
|
||||||
|
- ${CORS_ALLOWED_ORIGIN_PROD:https://bonsai.goutailler-olivier.com}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
CREATE TABLE issues (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
type VARCHAR(50) NOT NULL,
|
||||||
|
assignee VARCHAR(255),
|
||||||
|
epic VARCHAR(255),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
due_date DATE,
|
||||||
|
description TEXT,
|
||||||
|
estimated_time DOUBLE PRECISION,
|
||||||
|
priority VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'draft',
|
||||||
|
progress INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- No FK on depends_on_id: dangling refs allowed (frontend cleans up)
|
||||||
|
CREATE TABLE issue_depends_on (
|
||||||
|
issue_id BIGINT NOT NULL REFERENCES issues (id) ON DELETE CASCADE,
|
||||||
|
depends_on_id BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE comments (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
issue_id BIGINT NOT NULL REFERENCES issues (id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL,
|
||||||
|
updated_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user