Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions backend/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,13 @@
<scope>provided</scope>
</dependency>

<!-- OpenPDF for PDF generation -->
<dependency>
<groupId>com.github.librepdf</groupId>
<artifactId>openpdf</artifactId>
<version>2.0.3</version>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package backend.fullstack.export.api;

import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import backend.fullstack.config.JwtPrincipal;
import backend.fullstack.export.api.dto.ExportRequest;
import backend.fullstack.export.application.ExportService;
import backend.fullstack.export.domain.ExportFormat;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;

/**
* REST controller for exporting data as PDF or JSON.
*/
@RestController
@RequestMapping("/api/export")
@Tag(name = "Export", description = "Data export in PDF and JSON formats")
@SecurityRequirement(name = "Bearer Auth")
public class ExportController {

private final ExportService exportService;

public ExportController(ExportService exportService) {
this.exportService = exportService;
}

@PostMapping
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')")
@Operation(
summary = "Export data",
description = "Exports data from the specified module in PDF or JSON format. "
+ "Supports optional date range filtering."
)
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Export generated successfully"),
@ApiResponse(responseCode = "400", description = "Invalid request parameters"),
@ApiResponse(responseCode = "401", description = "Unauthorized"),
@ApiResponse(responseCode = "403", description = "Forbidden — missing REPORTS_EXPORT permission")
})
public ResponseEntity<byte[]> exportData(
@AuthenticationPrincipal JwtPrincipal principal,
@Valid @RequestBody ExportRequest request
) {
byte[] data = exportService.export(
principal.organizationId(),
request.module(),
request.format(),
request.from(),
request.to()
);

String filename = exportService.buildFilename(request.module(), request.format());
MediaType mediaType = request.format() == ExportFormat.PDF
? MediaType.APPLICATION_PDF
: MediaType.APPLICATION_JSON;

HttpHeaders headers = new HttpHeaders();
headers.setContentType(mediaType);
headers.setContentDisposition(
ContentDisposition.attachment().filename(filename).build()
);

return ResponseEntity.ok().headers(headers).body(data);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package backend.fullstack.export.api.dto;

import java.time.LocalDate;

import backend.fullstack.export.domain.ExportFormat;
import backend.fullstack.export.domain.ExportModule;
import jakarta.validation.constraints.NotNull;

/**
* Request body for data export.
*
* @param module the data module to export
* @param format desired output format (PDF or JSON)
* @param from optional start date filter (inclusive)
* @param to optional end date filter (inclusive)
*/
public record ExportRequest(
@NotNull(message = "Module is required")
ExportModule module,

@NotNull(message = "Format is required")
ExportFormat format,

LocalDate from,

LocalDate to
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package backend.fullstack.export.application;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import backend.fullstack.checklist.domain.ChecklistInstance;
import backend.fullstack.checklist.infrastructure.ChecklistInstanceRepository;
import backend.fullstack.deviations.domain.Deviation;
import backend.fullstack.deviations.infrastructure.DeviationRepository;
import backend.fullstack.export.domain.ExportFormat;
import backend.fullstack.export.domain.ExportModule;
import backend.fullstack.temperature.domain.TemperatureReading;
import backend.fullstack.temperature.infrastructure.TemperatureReadingRepository;

/**
* Service responsible for orchestrating data exports.
* Fetches data from the relevant module and delegates to format-specific generators.
*/
@Service
@Transactional(readOnly = true)
public class ExportService {

private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");

private final TemperatureReadingRepository temperatureReadingRepository;
private final DeviationRepository deviationRepository;
private final ChecklistInstanceRepository checklistInstanceRepository;
private final PdfExportGenerator pdfGenerator;
private final JsonExportGenerator jsonGenerator;

public ExportService(
TemperatureReadingRepository temperatureReadingRepository,
DeviationRepository deviationRepository,
ChecklistInstanceRepository checklistInstanceRepository,
PdfExportGenerator pdfGenerator,
JsonExportGenerator jsonGenerator
) {
this.temperatureReadingRepository = temperatureReadingRepository;
this.deviationRepository = deviationRepository;
this.checklistInstanceRepository = checklistInstanceRepository;
this.pdfGenerator = pdfGenerator;
this.jsonGenerator = jsonGenerator;
}

/**
* Exports data for the given module and format.
*
* @param organizationId the organization scope
* @param module the data module to export
* @param format desired output format
* @param from optional start date (inclusive)
* @param to optional end date (inclusive)
* @return byte array containing the exported file
*/
public byte[] export(Long organizationId, ExportModule module, ExportFormat format,
LocalDate from, LocalDate to) {
return switch (module) {
case TEMPERATURE_LOGS -> exportTemperatureLogs(organizationId, format, from, to);
case DEVIATIONS -> exportDeviations(organizationId, format);
case CHECKLISTS -> exportChecklists(organizationId, format);
};
}

/**
* Builds a descriptive filename for the export.
*/
public String buildFilename(ExportModule module, ExportFormat format) {
String moduleName = module.name().toLowerCase().replace('_', '-');
String date = LocalDate.now().format(DATE_FMT);
String extension = format == ExportFormat.PDF ? "pdf" : "json";
return moduleName + "-export-" + date + "." + extension;
}

private byte[] exportTemperatureLogs(Long organizationId, ExportFormat format,
LocalDate from, LocalDate to) {
LocalDateTime fromDt = from != null ? from.atStartOfDay() : null;
LocalDateTime toDt = to != null ? to.atTime(23, 59, 59) : null;

List<TemperatureReading> readings = temperatureReadingRepository
.findForStatsByOrganizationAndRange(organizationId, fromDt, toDt);

return format == ExportFormat.PDF
? pdfGenerator.generateTemperatureLogsPdf(readings, from, to)
: jsonGenerator.generateTemperatureLogsJson(readings);
}

private byte[] exportDeviations(Long organizationId, ExportFormat format) {
List<Deviation> deviations = deviationRepository
.findByOrganization_IdOrderByCreatedAtDesc(organizationId);

return format == ExportFormat.PDF
? pdfGenerator.generateDeviationsPdf(deviations)
: jsonGenerator.generateDeviationsJson(deviations);
}

private byte[] exportChecklists(Long organizationId, ExportFormat format) {
List<ChecklistInstance> checklists = checklistInstanceRepository
.findAllByOrganizationId(organizationId);

return format == ExportFormat.PDF
? pdfGenerator.generateChecklistsPdf(checklists)
: jsonGenerator.generateChecklistsJson(checklists);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package backend.fullstack.export.application;

import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import org.springframework.stereotype.Component;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;

import backend.fullstack.checklist.domain.ChecklistInstance;
import backend.fullstack.checklist.domain.ChecklistInstanceItem;
import backend.fullstack.deviations.domain.Deviation;
import backend.fullstack.temperature.domain.TemperatureReading;

/**
* Generates JSON export files from domain data.
*/
@Component
public class JsonExportGenerator {

private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final DateTimeFormatter D_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");

private final ObjectMapper objectMapper;

public JsonExportGenerator() {
this.objectMapper = new ObjectMapper();
this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
}

public byte[] generateTemperatureLogsJson(List<TemperatureReading> readings) {
List<Map<String, Object>> items = readings.stream().map(r -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", r.getId());
map.put("temperature", r.getTemperature());
map.put("unitName", r.getUnit() != null ? r.getUnit().getName() : null);
map.put("recordedAt", formatDateTime(r.getRecordedAt()));
map.put("recordedBy", r.getRecordedBy() != null
? r.getRecordedBy().getFirstName() + " " + r.getRecordedBy().getLastName()
: null);
map.put("note", r.getNote());
map.put("isDeviation", r.isDeviation());
return map;
}).toList();

Map<String, Object> root = new LinkedHashMap<>();
root.put("exportType", "TEMPERATURE_LOGS");
root.put("exportedAt", formatDateTime(LocalDateTime.now()));
root.put("totalRecords", items.size());
root.put("data", items);

return toBytes(root);
}

public byte[] generateDeviationsJson(List<Deviation> deviations) {
List<Map<String, Object>> items = deviations.stream().map(d -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", d.getId());
map.put("title", d.getTitle());
map.put("description", d.getDescription());
map.put("status", d.getStatus() != null ? d.getStatus().name() : null);
map.put("severity", d.getSeverity() != null ? d.getSeverity().name() : null);
map.put("moduleType", d.getModuleType() != null ? d.getModuleType().name() : null);
map.put("reportedBy", d.getReportedByName());
map.put("createdAt", formatDateTime(d.getCreatedAt()));
map.put("resolvedBy", d.getResolvedByName());
map.put("resolvedAt", formatDateTime(d.getResolvedAt()));
map.put("resolution", d.getResolution());
return map;
}).toList();

Map<String, Object> root = new LinkedHashMap<>();
root.put("exportType", "DEVIATIONS");
root.put("exportedAt", formatDateTime(LocalDateTime.now()));
root.put("totalRecords", items.size());
root.put("data", items);

return toBytes(root);
}

public byte[] generateChecklistsJson(List<ChecklistInstance> checklists) {
List<Map<String, Object>> items = checklists.stream().map(c -> {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", c.getId());
map.put("title", c.getTitle());
map.put("frequency", c.getFrequency() != null ? c.getFrequency().name() : null);
map.put("date", formatDate(c.getDate()));
map.put("status", c.getStatus() != null ? c.getStatus().name() : null);
map.put("items", c.getItems().stream().map(this::mapChecklistItem).toList());
return map;
}).toList();

Map<String, Object> root = new LinkedHashMap<>();
root.put("exportType", "CHECKLISTS");
root.put("exportedAt", formatDateTime(LocalDateTime.now()));
root.put("totalRecords", items.size());
root.put("data", items);

return toBytes(root);
}

private Map<String, Object> mapChecklistItem(ChecklistInstanceItem item) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", item.getId());
map.put("text", item.getText());
map.put("completed", item.isCompleted());
map.put("completedByUserId", item.getCompletedByUserId());
map.put("completedAt", item.getCompletedAt() != null ? item.getCompletedAt().toString() : null);
return map;
}

private String formatDateTime(LocalDateTime dt) {
return dt != null ? dt.format(DT_FMT) : null;
}

private String formatDate(LocalDate d) {
return d != null ? d.format(D_FMT) : null;
}

private byte[] toBytes(Object value) {
try {
return objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to generate JSON export", e);
}
}
}
Loading
Loading