diff --git a/backend/src/main/java/com/ldpv2/controller/DeploymentController.java b/backend/src/main/java/com/ldpv2/controller/DeploymentController.java new file mode 100644 index 0000000..5918cf6 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/controller/DeploymentController.java @@ -0,0 +1,110 @@ +package com.ldpv2.controller; + +import com.ldpv2.dto.request.RecordDeploymentRequest; +import com.ldpv2.dto.response.CurrentDeploymentStateResponse; +import com.ldpv2.dto.response.DeploymentResponse; +import com.ldpv2.service.DeploymentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/deployments") +@Tag(name = "Deployments", description = "Deployment tracking endpoints") +@SecurityRequirement(name = "bearerAuth") +public class DeploymentController { + + @Autowired + private DeploymentService deploymentService; + + @PostMapping + @Operation(summary = "Record deployment", description = "Record a new deployment") + public ResponseEntity recordDeployment( + @Valid @RequestBody RecordDeploymentRequest request) { + DeploymentResponse response = deploymentService.recordDeployment(request); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + @GetMapping("/{id}") + @Operation(summary = "Get deployment", description = "Get deployment by ID") + public ResponseEntity getById(@PathVariable UUID id) { + DeploymentResponse response = deploymentService.findById(id); + return ResponseEntity.ok(response); + } + + @GetMapping + @Operation(summary = "List deployments", description = "Get paginated list of deployments with optional filters") + public ResponseEntity> getAll( + @RequestParam(required = false) UUID applicationId, + @RequestParam(required = false) UUID environmentId, + @RequestParam(required = false) UUID versionId, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime dateFrom, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime dateTo, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "deploymentDate") String sortBy, + @RequestParam(defaultValue = "desc") String sortDirection) { + + Sort sort = sortDirection.equalsIgnoreCase("desc") + ? Sort.by(sortBy).descending() + : Sort.by(sortBy).ascending(); + + Pageable pageable = PageRequest.of(page, size, sort); + + Page response; + if (applicationId != null || environmentId != null || versionId != null || dateFrom != null || dateTo != null) { + response = deploymentService.search(applicationId, environmentId, versionId, dateFrom, dateTo, pageable); + } else { + response = deploymentService.findAll(pageable); + } + + return ResponseEntity.ok(response); + } + + @GetMapping("/current") + @Operation(summary = "Get current state", description = "Get current deployment state across environments") + public ResponseEntity> getCurrentState( + @RequestParam(required = false) UUID applicationId, + @RequestParam(required = false) UUID environmentId) { + List response = deploymentService.getCurrentState(applicationId, environmentId); + return ResponseEntity.ok(response); + } + + @GetMapping("/by-application/{applicationId}") + @Operation(summary = "Get deployments by application", description = "Get deployment history for an application") + public ResponseEntity> getByApplication( + @PathVariable UUID applicationId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, size, Sort.by("deploymentDate").descending()); + Page response = deploymentService.findByApplication(applicationId, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/by-environment/{environmentId}") + @Operation(summary = "Get deployments by environment", description = "Get all deployments to an environment") + public ResponseEntity> getByEnvironment( + @PathVariable UUID environmentId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + + Pageable pageable = PageRequest.of(page, size, Sort.by("deploymentDate").descending()); + Page response = deploymentService.findByEnvironment(environmentId, pageable); + return ResponseEntity.ok(response); + } +} diff --git a/backend/src/main/java/com/ldpv2/controller/VersionController.java b/backend/src/main/java/com/ldpv2/controller/VersionController.java new file mode 100644 index 0000000..35b802d --- /dev/null +++ b/backend/src/main/java/com/ldpv2/controller/VersionController.java @@ -0,0 +1,94 @@ +package com.ldpv2.controller; + +import com.ldpv2.dto.request.CreateVersionRequest; +import com.ldpv2.dto.request.UpdateVersionRequest; +import com.ldpv2.dto.response.VersionResponse; +import com.ldpv2.service.VersionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; +import java.util.UUID; + +@RestController +@RequestMapping("/applications/{applicationId}/versions") +@Tag(name = "Versions", description = "Version management endpoints") +@SecurityRequirement(name = "bearerAuth") +public class VersionController { + + @Autowired + private VersionService versionService; + + @PostMapping + @Operation(summary = "Create version", description = "Create a new version for an application") + public ResponseEntity create( + @PathVariable UUID applicationId, + @Valid @RequestBody CreateVersionRequest request) { + VersionResponse response = versionService.create(applicationId, request); + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + @PutMapping("/{id}") + @Operation(summary = "Update version", description = "Update an existing version") + public ResponseEntity update( + @PathVariable UUID applicationId, + @PathVariable UUID id, + @Valid @RequestBody UpdateVersionRequest request) { + VersionResponse response = versionService.update(id, request); + return ResponseEntity.ok(response); + } + + @GetMapping("/{id}") + @Operation(summary = "Get version", description = "Get version by ID") + public ResponseEntity getById( + @PathVariable UUID applicationId, + @PathVariable UUID id) { + VersionResponse response = versionService.findById(id); + return ResponseEntity.ok(response); + } + + @GetMapping + @Operation(summary = "List versions", description = "Get all versions for an application") + public ResponseEntity> getAll( + @PathVariable UUID applicationId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "releaseDate") String sortBy, + @RequestParam(defaultValue = "desc") String sortDirection) { + + Sort sort = sortDirection.equalsIgnoreCase("desc") + ? Sort.by(sortBy).descending() + : Sort.by(sortBy).ascending(); + + Pageable pageable = PageRequest.of(page, size, sort); + Page response = versionService.findByApplication(applicationId, pageable); + return ResponseEntity.ok(response); + } + + @GetMapping("/latest") + @Operation(summary = "Get latest version", description = "Get the most recent version for an application") + public ResponseEntity getLatest(@PathVariable UUID applicationId) { + Optional response = versionService.findLatestByApplication(applicationId); + return response.map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @DeleteMapping("/{id}") + @Operation(summary = "Delete version", description = "Delete a version") + public ResponseEntity delete( + @PathVariable UUID applicationId, + @PathVariable UUID id) { + versionService.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/com/ldpv2/domain/entity/Deployment.java b/backend/src/main/java/com/ldpv2/domain/entity/Deployment.java new file mode 100644 index 0000000..1159c3c --- /dev/null +++ b/backend/src/main/java/com/ldpv2/domain/entity/Deployment.java @@ -0,0 +1,42 @@ +package com.ldpv2.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Deployment entity - immutable record of deployments + * Tracks which version of which application is deployed to which environment + */ +@Data +@Entity +@Table(name = "deployment") +@NoArgsConstructor @AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class Deployment extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "application_id", nullable = false) + private Application application; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "version_id", nullable = false) + private Version version; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "environment_id", nullable = false) + private Environment environment; + + @Column(name = "deployment_date", nullable = false) + private LocalDateTime deploymentDate; + + @Column(name = "deployed_by", length = 255) + private String deployedBy; + + @Column(columnDefinition = "TEXT") + private String notes; +} diff --git a/backend/src/main/java/com/ldpv2/domain/entity/Version.java b/backend/src/main/java/com/ldpv2/domain/entity/Version.java new file mode 100644 index 0000000..37d2440 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/domain/entity/Version.java @@ -0,0 +1,40 @@ +package com.ldpv2.domain.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * Version entity representing application releases + */ +@Data +@Entity +@Table(name = "version", uniqueConstraints = { + @UniqueConstraint(name = "uk_version_app_identifier", + columnNames = {"application_id", "version_identifier"}) +}) +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class Version extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "application_id", nullable = false) + private Application application; + + @Column(name = "version_identifier", nullable = false, length = 100) + private String versionIdentifier; + + @Column(name = "external_reference", length = 500) + private String externalReference; + + @Column(name = "release_date", nullable = false) + private LocalDate releaseDate; + + @Column(name = "end_of_life_date") + private LocalDate endOfLifeDate; +} diff --git a/backend/src/main/java/com/ldpv2/dto/request/CreateVersionRequest.java b/backend/src/main/java/com/ldpv2/dto/request/CreateVersionRequest.java new file mode 100644 index 0000000..03cea8d --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/request/CreateVersionRequest.java @@ -0,0 +1,28 @@ +package com.ldpv2.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateVersionRequest { + + @NotBlank(message = "Version identifier is required") + @Size(max = 100, message = "Version identifier must not exceed 100 characters") + private String versionIdentifier; + + @Size(max = 500, message = "External reference must not exceed 500 characters") + private String externalReference; + + @NotNull(message = "Release date is required") + private LocalDate releaseDate; + + private LocalDate endOfLifeDate; +} diff --git a/backend/src/main/java/com/ldpv2/dto/request/RecordDeploymentRequest.java b/backend/src/main/java/com/ldpv2/dto/request/RecordDeploymentRequest.java new file mode 100644 index 0000000..582ac63 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/request/RecordDeploymentRequest.java @@ -0,0 +1,31 @@ +package com.ldpv2.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class RecordDeploymentRequest { + + @NotNull(message = "Application ID is required") + private UUID applicationId; + + @NotNull(message = "Version ID is required") + private UUID versionId; + + @NotNull(message = "Environment ID is required") + private UUID environmentId; + + @NotNull(message = "Deployment date is required") + private LocalDateTime deploymentDate; + + private String deployedBy; + + private String notes; +} diff --git a/backend/src/main/java/com/ldpv2/dto/request/UpdateVersionRequest.java b/backend/src/main/java/com/ldpv2/dto/request/UpdateVersionRequest.java new file mode 100644 index 0000000..eaec97e --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/request/UpdateVersionRequest.java @@ -0,0 +1,24 @@ +package com.ldpv2.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateVersionRequest { + + @Size(max = 100, message = "Version identifier must not exceed 100 characters") + private String versionIdentifier; + + @Size(max = 500, message = "External reference must not exceed 500 characters") + private String externalReference; + + private LocalDate releaseDate; + + private LocalDate endOfLifeDate; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/CurrentDeploymentStateResponse.java b/backend/src/main/java/com/ldpv2/dto/response/CurrentDeploymentStateResponse.java new file mode 100644 index 0000000..75f6c3b --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/CurrentDeploymentStateResponse.java @@ -0,0 +1,18 @@ +package com.ldpv2.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CurrentDeploymentStateResponse { + private ApplicationSummaryResponse application; + private EnvironmentSummaryResponse environment; + private VersionSummaryResponse version; + private LocalDateTime deploymentDate; + private String deployedBy; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/DeploymentResponse.java b/backend/src/main/java/com/ldpv2/dto/response/DeploymentResponse.java new file mode 100644 index 0000000..9661c1c --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/DeploymentResponse.java @@ -0,0 +1,22 @@ +package com.ldpv2.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class DeploymentResponse { + private UUID id; + private ApplicationSummaryResponse application; + private VersionSummaryResponse version; + private EnvironmentSummaryResponse environment; + private LocalDateTime deploymentDate; + private String deployedBy; + private String notes; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/EnvironmentSummaryResponse.java b/backend/src/main/java/com/ldpv2/dto/response/EnvironmentSummaryResponse.java new file mode 100644 index 0000000..a72920a --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/EnvironmentSummaryResponse.java @@ -0,0 +1,16 @@ +package com.ldpv2.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class EnvironmentSummaryResponse { + private UUID id; + private String name; + private boolean isProduction; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/VersionResponse.java b/backend/src/main/java/com/ldpv2/dto/response/VersionResponse.java new file mode 100644 index 0000000..f42519b --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/VersionResponse.java @@ -0,0 +1,24 @@ +package com.ldpv2.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class VersionResponse { + private UUID id; + private UUID applicationId; + private String applicationName; + private String versionIdentifier; + private String externalReference; + private LocalDate releaseDate; + private LocalDate endOfLifeDate; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/ldpv2/dto/response/VersionSummaryResponse.java b/backend/src/main/java/com/ldpv2/dto/response/VersionSummaryResponse.java new file mode 100644 index 0000000..150a4fa --- /dev/null +++ b/backend/src/main/java/com/ldpv2/dto/response/VersionSummaryResponse.java @@ -0,0 +1,17 @@ +package com.ldpv2.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class VersionSummaryResponse { + private UUID id; + private String versionIdentifier; + private LocalDate releaseDate; +} diff --git a/backend/src/main/java/com/ldpv2/repository/DeploymentRepository.java b/backend/src/main/java/com/ldpv2/repository/DeploymentRepository.java new file mode 100644 index 0000000..55e022c --- /dev/null +++ b/backend/src/main/java/com/ldpv2/repository/DeploymentRepository.java @@ -0,0 +1,64 @@ +package com.ldpv2.repository; + +import com.ldpv2.domain.entity.Deployment; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface DeploymentRepository extends JpaRepository { + + Page findByApplicationId(UUID applicationId, Pageable pageable); + + Page findByEnvironmentId(UUID environmentId, Pageable pageable); + + Page findByApplicationIdAndEnvironmentId(UUID applicationId, UUID environmentId, Pageable pageable); + + @Query("SELECT d FROM Deployment d WHERE " + + "(:applicationId IS NULL OR d.application.id = :applicationId) AND " + + "(:environmentId IS NULL OR d.environment.id = :environmentId) AND " + + "(:versionId IS NULL OR d.version.id = :versionId) AND " + + "(:dateFrom IS NULL OR d.deploymentDate >= :dateFrom) AND " + + "(:dateTo IS NULL OR d.deploymentDate <= :dateTo)") + Page search( + @Param("applicationId") UUID applicationId, + @Param("environmentId") UUID environmentId, + @Param("versionId") UUID versionId, + @Param("dateFrom") LocalDateTime dateFrom, + @Param("dateTo") LocalDateTime dateTo, + Pageable pageable + ); + + /** + * Get current deployment state - most recent deployment per application/environment + */ + @Query("SELECT d FROM Deployment d WHERE d.id IN (" + + " SELECT MAX(d2.id) FROM Deployment d2 " + + " WHERE (:applicationId IS NULL OR d2.application.id = :applicationId) AND " + + " (:environmentId IS NULL OR d2.environment.id = :environmentId) " + + " GROUP BY d2.application.id, d2.environment.id" + + ")") + List findCurrentState( + @Param("applicationId") UUID applicationId, + @Param("environmentId") UUID environmentId + ); + + /** + * Get current deployment for specific application in specific environment + */ + @Query("SELECT d FROM Deployment d " + + "WHERE d.application.id = :applicationId AND d.environment.id = :environmentId " + + "ORDER BY d.deploymentDate DESC LIMIT 1") + Optional findCurrentForApplicationInEnvironment( + @Param("applicationId") UUID applicationId, + @Param("environmentId") UUID environmentId + ); +} diff --git a/backend/src/main/java/com/ldpv2/repository/VersionRepository.java b/backend/src/main/java/com/ldpv2/repository/VersionRepository.java new file mode 100644 index 0000000..94ceefb --- /dev/null +++ b/backend/src/main/java/com/ldpv2/repository/VersionRepository.java @@ -0,0 +1,25 @@ +package com.ldpv2.repository; + +import com.ldpv2.domain.entity.Version; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface VersionRepository extends JpaRepository { + + Page findByApplicationId(UUID applicationId, Pageable pageable); + + Optional findByApplicationIdAndVersionIdentifier(UUID applicationId, String versionIdentifier); + + boolean existsByApplicationIdAndVersionIdentifier(UUID applicationId, String versionIdentifier); + + @Query("SELECT v FROM Version v WHERE v.application.id = :applicationId ORDER BY v.releaseDate DESC LIMIT 1") + Optional findLatestByApplicationId(@Param("applicationId") UUID applicationId); +} diff --git a/backend/src/main/java/com/ldpv2/service/DeploymentService.java b/backend/src/main/java/com/ldpv2/service/DeploymentService.java new file mode 100644 index 0000000..0d20882 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/service/DeploymentService.java @@ -0,0 +1,189 @@ +package com.ldpv2.service; + +import com.ldpv2.domain.entity.Application; +import com.ldpv2.domain.entity.Deployment; +import com.ldpv2.domain.entity.Environment; +import com.ldpv2.domain.entity.Version; +import com.ldpv2.dto.request.RecordDeploymentRequest; +import com.ldpv2.dto.response.*; +import com.ldpv2.exception.BadRequestException; +import com.ldpv2.exception.ResourceNotFoundException; +import com.ldpv2.repository.ApplicationRepository; +import com.ldpv2.repository.DeploymentRepository; +import com.ldpv2.repository.EnvironmentRepository; +import com.ldpv2.repository.VersionRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class DeploymentService { + + @Autowired + private DeploymentRepository deploymentRepository; + + @Autowired + private ApplicationRepository applicationRepository; + + @Autowired + private VersionRepository versionRepository; + + @Autowired + private EnvironmentRepository environmentRepository; + + @Transactional + public DeploymentResponse recordDeployment(RecordDeploymentRequest request) { + // Validate application exists + Application application = applicationRepository.findById(request.getApplicationId()) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + request.getApplicationId())); + + // Validate version exists + Version version = versionRepository.findById(request.getVersionId()) + .orElseThrow(() -> new ResourceNotFoundException( + "Version not found with id: " + request.getVersionId())); + + // Validate environment exists + Environment environment = environmentRepository.findById(request.getEnvironmentId()) + .orElseThrow(() -> new ResourceNotFoundException( + "Environment not found with id: " + request.getEnvironmentId())); + + // Validate that version belongs to the application + if (!version.getApplication().getId().equals(request.getApplicationId())) { + throw new BadRequestException( + "Version does not belong to the specified application"); + } + + // Validate deployment date is not in future + if (request.getDeploymentDate().isAfter(LocalDateTime.now())) { + throw new BadRequestException("Deployment date cannot be in the future"); + } + + Deployment deployment = new Deployment(); + deployment.setApplication(application); + deployment.setVersion(version); + deployment.setEnvironment(environment); + deployment.setDeploymentDate(request.getDeploymentDate()); + deployment.setDeployedBy(request.getDeployedBy()); + deployment.setNotes(request.getNotes()); + + deployment = deploymentRepository.save(deployment); + return mapToResponse(deployment); + } + + public DeploymentResponse findById(UUID id) { + Deployment deployment = deploymentRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Deployment not found with id: " + id)); + return mapToResponse(deployment); + } + + public Page findAll(Pageable pageable) { + return deploymentRepository.findAll(pageable).map(this::mapToResponse); + } + + public Page findByApplication(UUID applicationId, Pageable pageable) { + if (!applicationRepository.existsById(applicationId)) { + throw new ResourceNotFoundException( + "Application not found with id: " + applicationId); + } + return deploymentRepository.findByApplicationId(applicationId, pageable) + .map(this::mapToResponse); + } + + public Page findByEnvironment(UUID environmentId, Pageable pageable) { + if (!environmentRepository.existsById(environmentId)) { + throw new ResourceNotFoundException( + "Environment not found with id: " + environmentId); + } + return deploymentRepository.findByEnvironmentId(environmentId, pageable) + .map(this::mapToResponse); + } + + public Page search( + UUID applicationId, + UUID environmentId, + UUID versionId, + LocalDateTime dateFrom, + LocalDateTime dateTo, + Pageable pageable) { + return deploymentRepository.search( + applicationId, environmentId, versionId, dateFrom, dateTo, pageable) + .map(this::mapToResponse); + } + + public List getCurrentState(UUID applicationId, UUID environmentId) { + List deployments = deploymentRepository.findCurrentState(applicationId, environmentId); + return deployments.stream() + .map(this::mapToCurrentStateResponse) + .collect(Collectors.toList()); + } + + private DeploymentResponse mapToResponse(Deployment deployment) { + ApplicationSummaryResponse appSummary = new ApplicationSummaryResponse( + deployment.getApplication().getId(), + deployment.getApplication().getName(), + deployment.getApplication().getStatus(), + deployment.getApplication().getBusinessUnit().getName() + ); + + VersionSummaryResponse versionSummary = new VersionSummaryResponse( + deployment.getVersion().getId(), + deployment.getVersion().getVersionIdentifier(), + deployment.getVersion().getReleaseDate() + ); + + EnvironmentSummaryResponse envSummary = new EnvironmentSummaryResponse( + deployment.getEnvironment().getId(), + deployment.getEnvironment().getName(), + deployment.getEnvironment().getIsProduction() + ); + + return new DeploymentResponse( + deployment.getId(), + appSummary, + versionSummary, + envSummary, + deployment.getDeploymentDate(), + deployment.getDeployedBy(), + deployment.getNotes(), + deployment.getCreatedAt() + ); + } + + private CurrentDeploymentStateResponse mapToCurrentStateResponse(Deployment deployment) { + ApplicationSummaryResponse appSummary = new ApplicationSummaryResponse( + deployment.getApplication().getId(), + deployment.getApplication().getName(), + deployment.getApplication().getStatus(), + deployment.getApplication().getBusinessUnit().getName() + ); + + VersionSummaryResponse versionSummary = new VersionSummaryResponse( + deployment.getVersion().getId(), + deployment.getVersion().getVersionIdentifier(), + deployment.getVersion().getReleaseDate() + ); + + EnvironmentSummaryResponse envSummary = new EnvironmentSummaryResponse( + deployment.getEnvironment().getId(), + deployment.getEnvironment().getName(), + deployment.getEnvironment().getIsProduction() + ); + + return new CurrentDeploymentStateResponse( + appSummary, + envSummary, + versionSummary, + deployment.getDeploymentDate(), + deployment.getDeployedBy() + ); + } +} diff --git a/backend/src/main/java/com/ldpv2/service/VersionService.java b/backend/src/main/java/com/ldpv2/service/VersionService.java new file mode 100644 index 0000000..0a67f92 --- /dev/null +++ b/backend/src/main/java/com/ldpv2/service/VersionService.java @@ -0,0 +1,160 @@ +package com.ldpv2.service; + +import com.ldpv2.domain.entity.Application; +import com.ldpv2.domain.entity.Version; +import com.ldpv2.dto.request.CreateVersionRequest; +import com.ldpv2.dto.request.UpdateVersionRequest; +import com.ldpv2.dto.response.VersionResponse; +import com.ldpv2.exception.BadRequestException; +import com.ldpv2.exception.ResourceNotFoundException; +import com.ldpv2.repository.ApplicationRepository; +import com.ldpv2.repository.VersionRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.UUID; + +@Service +public class VersionService { + + @Autowired + private VersionRepository versionRepository; + + @Autowired + private ApplicationRepository applicationRepository; + + @Transactional + public VersionResponse create(UUID applicationId, CreateVersionRequest request) { + Application application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new ResourceNotFoundException( + "Application not found with id: " + applicationId)); + + // Check if version identifier already exists for this application + if (versionRepository.existsByApplicationIdAndVersionIdentifier( + applicationId, request.getVersionIdentifier())) { + throw new BadRequestException( + "Version '" + request.getVersionIdentifier() + + "' already exists for this application"); + } + + // Validate dates + if (request.getEndOfLifeDate() != null && + request.getReleaseDate().isAfter(request.getEndOfLifeDate())) { + throw new BadRequestException( + "End of life date must be after release date"); + } + + // Validate release date is not in future + if (request.getReleaseDate().isAfter(LocalDate.now())) { + throw new BadRequestException("Release date cannot be in the future"); + } + + Version version = new Version(); + version.setApplication(application); + version.setVersionIdentifier(request.getVersionIdentifier()); + version.setExternalReference(request.getExternalReference()); + version.setReleaseDate(request.getReleaseDate()); + version.setEndOfLifeDate(request.getEndOfLifeDate()); + + version = versionRepository.save(version); + return mapToResponse(version); + } + + @Transactional + public VersionResponse update(UUID id, UpdateVersionRequest request) { + Version version = versionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Version not found with id: " + id)); + + if (request.getVersionIdentifier() != null) { + // Check if new version identifier already exists for this application + if (!request.getVersionIdentifier().equals(version.getVersionIdentifier()) && + versionRepository.existsByApplicationIdAndVersionIdentifier( + version.getApplication().getId(), request.getVersionIdentifier())) { + throw new BadRequestException( + "Version '" + request.getVersionIdentifier() + + "' already exists for this application"); + } + version.setVersionIdentifier(request.getVersionIdentifier()); + } + + if (request.getExternalReference() != null) { + version.setExternalReference(request.getExternalReference()); + } + + if (request.getReleaseDate() != null) { + if (request.getReleaseDate().isAfter(LocalDate.now())) { + throw new BadRequestException("Release date cannot be in the future"); + } + version.setReleaseDate(request.getReleaseDate()); + } + + if (request.getEndOfLifeDate() != null) { + version.setEndOfLifeDate(request.getEndOfLifeDate()); + } + + // Validate dates + if (version.getEndOfLifeDate() != null && + version.getReleaseDate().isAfter(version.getEndOfLifeDate())) { + throw new BadRequestException( + "End of life date must be after release date"); + } + + version = versionRepository.save(version); + return mapToResponse(version); + } + + public VersionResponse findById(UUID id) { + Version version = versionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Version not found with id: " + id)); + return mapToResponse(version); + } + + public Page findByApplication(UUID applicationId, Pageable pageable) { + if (!applicationRepository.existsById(applicationId)) { + throw new ResourceNotFoundException( + "Application not found with id: " + applicationId); + } + return versionRepository.findByApplicationId(applicationId, pageable) + .map(this::mapToResponse); + } + + public Optional findLatestByApplication(UUID applicationId) { + if (!applicationRepository.existsById(applicationId)) { + throw new ResourceNotFoundException( + "Application not found with id: " + applicationId); + } + return versionRepository.findLatestByApplicationId(applicationId) + .map(this::mapToResponse); + } + + @Transactional + public void delete(UUID id) { + Version version = versionRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException( + "Version not found with id: " + id)); + + // Note: In production, check if version is deployed anywhere before deletion + versionRepository.delete(version); + } + + private VersionResponse mapToResponse(Version version) { + return new VersionResponse( + version.getId(), + version.getApplication().getId(), + version.getApplication().getName(), + version.getVersionIdentifier(), + version.getExternalReference(), + version.getReleaseDate(), + version.getEndOfLifeDate(), + version.getCreatedAt(), + version.getUpdatedAt() + ); + } +} diff --git a/backend/src/main/resources/db/changelog/db.changelog-master.xml b/backend/src/main/resources/db/changelog/db.changelog-master.xml index 68c0dd1..7f2e29a 100644 --- a/backend/src/main/resources/db/changelog/db.changelog-master.xml +++ b/backend/src/main/resources/db/changelog/db.changelog-master.xml @@ -5,18 +5,13 @@ xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd"> - - - - - - - + + diff --git a/backend/src/main/resources/db/changelog/v1.0/006-create-version-table.xml b/backend/src/main/resources/db/changelog/v1.0/006-create-version-table.xml new file mode 100644 index 0000000..204f11e --- /dev/null +++ b/backend/src/main/resources/db/changelog/v1.0/006-create-version-table.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/db/changelog/v1.0/007-create-deployment-table.xml b/backend/src/main/resources/db/changelog/v1.0/007-create-deployment-table.xml new file mode 100644 index 0000000..69366db --- /dev/null +++ b/backend/src/main/resources/db/changelog/v1.0/007-create-deployment-table.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/app/features/deployments/deployment.service.ts b/frontend/src/app/features/deployments/deployment.service.ts new file mode 100644 index 0000000..770d64a --- /dev/null +++ b/frontend/src/app/features/deployments/deployment.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Deployment, RecordDeploymentRequest, CurrentDeploymentState } from '../../shared/models/deployment.model'; +import { Page } from '../../shared/models/environment.model'; + +@Injectable({ + providedIn: 'root' +}) +export class DeploymentService { + private readonly API_URL = '/api/deployments'; + + constructor(private http: HttpClient) {} + + getDeployments( + filters?: { + applicationId?: string; + environmentId?: string; + versionId?: string; + dateFrom?: Date; + dateTo?: Date; + }, + page: number = 0, + size: number = 20, + sortBy: string = 'deploymentDate', + sortDirection: string = 'desc' + ): Observable> { + let params = new HttpParams() + .set('page', page.toString()) + .set('size', size.toString()) + .set('sortBy', sortBy) + .set('sortDirection', sortDirection); + + if (filters?.applicationId) { + params = params.set('applicationId', filters.applicationId); + } + if (filters?.environmentId) { + params = params.set('environmentId', filters.environmentId); + } + if (filters?.versionId) { + params = params.set('versionId', filters.versionId); + } + if (filters?.dateFrom) { + params = params.set('dateFrom', filters.dateFrom.toISOString()); + } + if (filters?.dateTo) { + params = params.set('dateTo', filters.dateTo.toISOString()); + } + + return this.http.get>(this.API_URL, { params }); + } + + getDeployment(id: string): Observable { + return this.http.get(`${this.API_URL}/${id}`); + } + + getDeploymentsByApplication(applicationId: string, page: number = 0, size: number = 20): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('size', size.toString()); + + return this.http.get>( + `${this.API_URL}/by-application/${applicationId}`, + { params } + ); + } + + getDeploymentsByEnvironment(environmentId: string, page: number = 0, size: number = 20): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('size', size.toString()); + + return this.http.get>( + `${this.API_URL}/by-environment/${environmentId}`, + { params } + ); + } + + getCurrentState(applicationId?: string, environmentId?: string): Observable { + let params = new HttpParams(); + if (applicationId) { + params = params.set('applicationId', applicationId); + } + if (environmentId) { + params = params.set('environmentId', environmentId); + } + + return this.http.get( + `${this.API_URL}/current`, + { params } + ); + } + + recordDeployment(data: RecordDeploymentRequest): Observable { + return this.http.post(this.API_URL, data); + } +} diff --git a/frontend/src/app/features/versions/version.service.ts b/frontend/src/app/features/versions/version.service.ts new file mode 100644 index 0000000..d26ada1 --- /dev/null +++ b/frontend/src/app/features/versions/version.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { Version, CreateVersionRequest, UpdateVersionRequest } from '../../shared/models/version.model'; +import { Page } from '../../shared/models/environment.model'; + +@Injectable({ + providedIn: 'root' +}) +export class VersionService { + + constructor(private http: HttpClient) {} + + getVersions( + applicationId: string, + page: number = 0, + size: number = 20, + sortBy: string = 'releaseDate', + sortDirection: string = 'desc' + ): Observable> { + const params = new HttpParams() + .set('page', page.toString()) + .set('size', size.toString()) + .set('sortBy', sortBy) + .set('sortDirection', sortDirection); + + return this.http.get>( + `/api/applications/${applicationId}/versions`, + { params } + ); + } + + getVersion(applicationId: string, versionId: string): Observable { + return this.http.get( + `/api/applications/${applicationId}/versions/${versionId}` + ); + } + + getLatestVersion(applicationId: string): Observable { + return this.http.get( + `/api/applications/${applicationId}/versions/latest` + ); + } + + createVersion(applicationId: string, data: CreateVersionRequest): Observable { + return this.http.post( + `/api/applications/${applicationId}/versions`, + data + ); + } + + updateVersion(applicationId: string, versionId: string, data: UpdateVersionRequest): Observable { + return this.http.put( + `/api/applications/${applicationId}/versions/${versionId}`, + data + ); + } + + deleteVersion(applicationId: string, versionId: string): Observable { + return this.http.delete( + `/api/applications/${applicationId}/versions/${versionId}` + ); + } +} diff --git a/frontend/src/app/shared/models/deployment.model.ts b/frontend/src/app/shared/models/deployment.model.ts new file mode 100644 index 0000000..ddeba12 --- /dev/null +++ b/frontend/src/app/shared/models/deployment.model.ts @@ -0,0 +1,55 @@ +import { ApplicationStatus } from './application.model'; + +export interface Deployment { + id: string; + application: { + id: string; + name: string; + status: ApplicationStatus; + businessUnitName: string; + }; + version: { + id: string; + versionIdentifier: string; + releaseDate: Date; + }; + environment: { + id: string; + name: string; + isProduction: boolean; + }; + deploymentDate: Date; + deployedBy?: string; + notes?: string; + createdAt: Date; +} + +export interface RecordDeploymentRequest { + applicationId: string; + versionId: string; + environmentId: string; + deploymentDate: Date; + deployedBy?: string; + notes?: string; +} + +export interface CurrentDeploymentState { + application: { + id: string; + name: string; + status: ApplicationStatus; + businessUnitName: string; + }; + environment: { + id: string; + name: string; + isProduction: boolean; + }; + version: { + id: string; + versionIdentifier: string; + releaseDate: Date; + }; + deploymentDate: Date; + deployedBy?: string; +} diff --git a/frontend/src/app/shared/models/version.model.ts b/frontend/src/app/shared/models/version.model.ts new file mode 100644 index 0000000..f5f6c86 --- /dev/null +++ b/frontend/src/app/shared/models/version.model.ts @@ -0,0 +1,31 @@ +export interface Version { + id: string; + applicationId: string; + applicationName: string; + versionIdentifier: string; + externalReference?: string; + releaseDate: Date; + endOfLifeDate?: Date; + createdAt: Date; + updatedAt: Date; +} + +export interface VersionSummary { + id: string; + versionIdentifier: string; + releaseDate: Date; +} + +export interface CreateVersionRequest { + versionIdentifier: string; + externalReference?: string; + releaseDate: Date; + endOfLifeDate?: Date; +} + +export interface UpdateVersionRequest { + versionIdentifier?: string; + externalReference?: string; + releaseDate?: Date; + endOfLifeDate?: Date; +}