api issue
This commit is contained in:
@@ -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;
|
||||
|
||||
import fr.bonsai.api.adapter.out.persistence.InMemoryBonsaiRepository;
|
||||
import fr.bonsai.api.application.port.out.BonsaiRepository;
|
||||
import fr.bonsai.api.application.usecase.BonsaiService;
|
||||
import fr.bonsai.api.adapter.out.persistence.IssueJpaRepository;
|
||||
import fr.bonsai.api.adapter.out.persistence.JpaIssueRepositoryAdapter;
|
||||
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.Configuration;
|
||||
|
||||
@@ -10,12 +11,12 @@ import org.springframework.context.annotation.Configuration;
|
||||
public class BeanConfig {
|
||||
|
||||
@Bean
|
||||
public BonsaiRepository bonsaiRepository() {
|
||||
return new InMemoryBonsaiRepository();
|
||||
public IssueRepository issueRepository(IssueJpaRepository jpaRepository) {
|
||||
return new JpaIssueRepositoryAdapter(jpaRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public BonsaiService bonsaiService(BonsaiRepository repository) {
|
||||
return new BonsaiService(repository);
|
||||
public IssueService issueService(IssueRepository issueRepository) {
|
||||
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