api issue

This commit is contained in:
2026-05-24 09:27:43 +02:00
parent a43ad25ee3
commit e6d06cb82f
40 changed files with 1078 additions and 198 deletions
@@ -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
+25
View File
@@ -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
);