diff --git a/backend/pom.xml b/backend/pom.xml index daab5d7..5302320 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -152,6 +152,13 @@ provided + + + com.github.librepdf + openpdf + 2.0.3 + + org.projectlombok lombok-mapstruct-binding diff --git a/backend/src/main/java/backend/fullstack/export/api/ExportController.java b/backend/src/main/java/backend/fullstack/export/api/ExportController.java new file mode 100644 index 0000000..9009b8e --- /dev/null +++ b/backend/src/main/java/backend/fullstack/export/api/ExportController.java @@ -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 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); + } +} diff --git a/backend/src/main/java/backend/fullstack/export/api/dto/ExportRequest.java b/backend/src/main/java/backend/fullstack/export/api/dto/ExportRequest.java new file mode 100644 index 0000000..c64a458 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/export/api/dto/ExportRequest.java @@ -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 +) {} diff --git a/backend/src/main/java/backend/fullstack/export/application/ExportService.java b/backend/src/main/java/backend/fullstack/export/application/ExportService.java new file mode 100644 index 0000000..fd5dcba --- /dev/null +++ b/backend/src/main/java/backend/fullstack/export/application/ExportService.java @@ -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 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 deviations = deviationRepository + .findByOrganization_IdOrderByCreatedAtDesc(organizationId); + + return format == ExportFormat.PDF + ? pdfGenerator.generateDeviationsPdf(deviations) + : jsonGenerator.generateDeviationsJson(deviations); + } + + private byte[] exportChecklists(Long organizationId, ExportFormat format) { + List checklists = checklistInstanceRepository + .findAllByOrganizationId(organizationId); + + return format == ExportFormat.PDF + ? pdfGenerator.generateChecklistsPdf(checklists) + : jsonGenerator.generateChecklistsJson(checklists); + } +} diff --git a/backend/src/main/java/backend/fullstack/export/application/JsonExportGenerator.java b/backend/src/main/java/backend/fullstack/export/application/JsonExportGenerator.java new file mode 100644 index 0000000..3673359 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/export/application/JsonExportGenerator.java @@ -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 readings) { + List> items = readings.stream().map(r -> { + Map 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 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 deviations) { + List> items = deviations.stream().map(d -> { + Map 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 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 checklists) { + List> items = checklists.stream().map(c -> { + Map 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 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 mapChecklistItem(ChecklistInstanceItem item) { + Map 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); + } + } +} diff --git a/backend/src/main/java/backend/fullstack/export/application/PdfExportGenerator.java b/backend/src/main/java/backend/fullstack/export/application/PdfExportGenerator.java new file mode 100644 index 0000000..e98bbb7 --- /dev/null +++ b/backend/src/main/java/backend/fullstack/export/application/PdfExportGenerator.java @@ -0,0 +1,274 @@ +package backend.fullstack.export.application; + +import java.io.ByteArrayOutputStream; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.lowagie.text.Document; +import com.lowagie.text.DocumentException; +import com.lowagie.text.Element; +import com.lowagie.text.Font; +import com.lowagie.text.FontFactory; +import com.lowagie.text.HeaderFooter; +import com.lowagie.text.PageSize; +import com.lowagie.text.Paragraph; +import com.lowagie.text.Phrase; +import com.lowagie.text.pdf.PdfPCell; +import com.lowagie.text.pdf.PdfPTable; +import com.lowagie.text.pdf.PdfWriter; + +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 PDF export files from domain data using OpenPDF. + */ +@Component +public class PdfExportGenerator { + + private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final DateTimeFormatter D_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private static final Font TITLE_FONT = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 18); + private static final Font SUBTITLE_FONT = FontFactory.getFont(FontFactory.HELVETICA, 11, Font.ITALIC); + private static final Font HEADER_FONT = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 10); + private static final Font CELL_FONT = FontFactory.getFont(FontFactory.HELVETICA, 9); + + private static final java.awt.Color HEADER_BG = new java.awt.Color(59, 130, 246); + private static final java.awt.Color HEADER_FG = java.awt.Color.WHITE; + private static final java.awt.Color STRIPE_BG = new java.awt.Color(243, 244, 246); + + // ──────────────────────────────────────────────────────────────── + // Temperature logs + // ──────────────────────────────────────────────────────────────── + + public byte[] generateTemperatureLogsPdf(List readings, + LocalDate from, LocalDate to) { + return createPdf(doc -> { + addTitle(doc, "Temperature Logs Export"); + addDateRange(doc, from, to); + addSpacer(doc); + + if (readings.isEmpty()) { + doc.add(new Paragraph("No temperature readings found for the given period.")); + return; + } + + PdfPTable table = new PdfPTable(new float[]{0.8f, 2f, 1.5f, 2f, 2f, 1f}); + table.setWidthPercentage(100); + addHeaders(table, "ID", "Unit", "Temp (°C)", "Recorded At", "Recorded By", "Deviation"); + + for (int i = 0; i < readings.size(); i++) { + TemperatureReading r = readings.get(i); + boolean stripe = i % 2 == 1; + addCell(table, str(r.getId()), stripe); + addCell(table, r.getUnit() != null ? r.getUnit().getName() : "-", stripe); + addCell(table, str(r.getTemperature()), stripe); + addCell(table, formatDateTime(r.getRecordedAt()), stripe); + addCell(table, r.getRecordedBy() != null + ? r.getRecordedBy().getFirstName() + " " + r.getRecordedBy().getLastName() + : "-", stripe); + addCell(table, r.isDeviation() ? "Yes" : "No", stripe); + } + + doc.add(table); + addRecordCount(doc, readings.size()); + }); + } + + // ──────────────────────────────────────────────────────────────── + // Deviations + // ──────────────────────────────────────────────────────────────── + + public byte[] generateDeviationsPdf(List deviations) { + return createPdf(doc -> { + addTitle(doc, "Deviations Export"); + addGeneratedTimestamp(doc); + addSpacer(doc); + + if (deviations.isEmpty()) { + doc.add(new Paragraph("No deviations found.")); + return; + } + + PdfPTable table = new PdfPTable(new float[]{0.6f, 2.5f, 1.2f, 1.2f, 1.5f, 2f, 1.5f}); + table.setWidthPercentage(100); + addHeaders(table, "ID", "Title", "Status", "Severity", "Module", "Reported By", "Created"); + + for (int i = 0; i < deviations.size(); i++) { + Deviation d = deviations.get(i); + boolean stripe = i % 2 == 1; + addCell(table, str(d.getId()), stripe); + addCell(table, d.getTitle(), stripe); + addCell(table, d.getStatus() != null ? d.getStatus().name() : "-", stripe); + addCell(table, d.getSeverity() != null ? d.getSeverity().name() : "-", stripe); + addCell(table, d.getModuleType() != null ? d.getModuleType().name() : "-", stripe); + addCell(table, d.getReportedByName() != null ? d.getReportedByName() : "-", stripe); + addCell(table, formatDateTime(d.getCreatedAt()), stripe); + } + + doc.add(table); + addRecordCount(doc, deviations.size()); + }); + } + + // ──────────────────────────────────────────────────────────────── + // Checklists + // ──────────────────────────────────────────────────────────────── + + public byte[] generateChecklistsPdf(List checklists) { + return createPdf(doc -> { + addTitle(doc, "Checklists Export"); + addGeneratedTimestamp(doc); + addSpacer(doc); + + if (checklists.isEmpty()) { + doc.add(new Paragraph("No checklists found.")); + return; + } + + for (ChecklistInstance cl : checklists) { + Paragraph header = new Paragraph(cl.getTitle(), FontFactory.getFont(FontFactory.HELVETICA_BOLD, 13)); + doc.add(header); + + String meta = String.format("Date: %s | Status: %s | Frequency: %s", + formatDate(cl.getDate()), + cl.getStatus() != null ? cl.getStatus().name() : "-", + cl.getFrequency() != null ? cl.getFrequency().name() : "-"); + doc.add(new Paragraph(meta, SUBTITLE_FONT)); + addSpacer(doc); + + if (!cl.getItems().isEmpty()) { + PdfPTable table = new PdfPTable(new float[]{0.5f, 3f, 1f}); + table.setWidthPercentage(100); + addHeaders(table, "#", "Item", "Completed"); + + int idx = 1; + for (ChecklistInstanceItem item : cl.getItems()) { + boolean stripe = idx % 2 == 0; + addCell(table, String.valueOf(idx), stripe); + addCell(table, item.getText(), stripe); + addCell(table, item.isCompleted() ? "✓" : "✗", stripe); + idx++; + } + doc.add(table); + } + + addSpacer(doc); + } + + addRecordCount(doc, checklists.size()); + }); + } + + // ──────────────────────────────────────────────────────────────── + // Shared helpers + // ──────────────────────────────────────────────────────────────── + + private byte[] createPdf(PdfContentWriter writer) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Document document = new Document(PageSize.A4, 36, 36, 50, 50); + + try { + PdfWriter.getInstance(document, baos); + + HeaderFooter footer = new HeaderFooter( + new Phrase("Page ", CELL_FONT), true); + footer.setAlignment(Element.ALIGN_CENTER); + footer.setBorder(0); + document.setFooter(footer); + + document.open(); + writer.write(document); + } catch (DocumentException e) { + throw new IllegalStateException("Failed to generate PDF export", e); + } finally { + document.close(); + } + + return baos.toByteArray(); + } + + private void addTitle(Document doc, String title) throws DocumentException { + Paragraph p = new Paragraph(title, TITLE_FONT); + p.setSpacingAfter(4); + doc.add(p); + } + + private void addDateRange(Document doc, LocalDate from, LocalDate to) throws DocumentException { + String range = "Period: " + + (from != null ? from.format(D_FMT) : "all") + + " — " + + (to != null ? to.format(D_FMT) : "present"); + Paragraph p = new Paragraph(range, SUBTITLE_FONT); + p.setSpacingAfter(2); + doc.add(p); + addGeneratedTimestamp(doc); + } + + private void addGeneratedTimestamp(Document doc) throws DocumentException { + Paragraph p = new Paragraph( + "Generated: " + LocalDateTime.now().format(DT_FMT), + SUBTITLE_FONT); + p.setSpacingAfter(2); + doc.add(p); + } + + private void addSpacer(Document doc) throws DocumentException { + Paragraph spacer = new Paragraph(" "); + spacer.setSpacingAfter(8); + doc.add(spacer); + } + + private void addRecordCount(Document doc, int count) throws DocumentException { + addSpacer(doc); + doc.add(new Paragraph("Total records: " + count, SUBTITLE_FONT)); + } + + private void addHeaders(PdfPTable table, String... headers) { + for (String h : headers) { + PdfPCell cell = new PdfPCell(new Phrase(h, HEADER_FONT)); + cell.setBackgroundColor(HEADER_BG); + cell.setHorizontalAlignment(Element.ALIGN_CENTER); + cell.setPadding(5); + + Font headerCellFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 10); + headerCellFont.setColor(HEADER_FG); + cell.setPhrase(new Phrase(h, headerCellFont)); + + table.addCell(cell); + } + } + + private void addCell(PdfPTable table, String text, boolean stripe) { + PdfPCell cell = new PdfPCell(new Phrase(text != null ? text : "-", CELL_FONT)); + if (stripe) { + cell.setBackgroundColor(STRIPE_BG); + } + cell.setPadding(4); + table.addCell(cell); + } + + private String formatDateTime(LocalDateTime dt) { + return dt != null ? dt.format(DT_FMT) : "-"; + } + + private String formatDate(LocalDate d) { + return d != null ? d.format(D_FMT) : "-"; + } + + private String str(Object obj) { + return obj != null ? obj.toString() : "-"; + } + + @FunctionalInterface + private interface PdfContentWriter { + void write(Document document) throws DocumentException; + } +} diff --git a/backend/src/main/java/backend/fullstack/export/domain/ExportFormat.java b/backend/src/main/java/backend/fullstack/export/domain/ExportFormat.java new file mode 100644 index 0000000..c3a66fd --- /dev/null +++ b/backend/src/main/java/backend/fullstack/export/domain/ExportFormat.java @@ -0,0 +1,9 @@ +package backend.fullstack.export.domain; + +/** + * Supported export file formats. + */ +public enum ExportFormat { + PDF, + JSON +} diff --git a/backend/src/main/java/backend/fullstack/export/domain/ExportModule.java b/backend/src/main/java/backend/fullstack/export/domain/ExportModule.java new file mode 100644 index 0000000..d67307f --- /dev/null +++ b/backend/src/main/java/backend/fullstack/export/domain/ExportModule.java @@ -0,0 +1,10 @@ +package backend.fullstack.export.domain; + +/** + * Modules that support data export. + */ +public enum ExportModule { + TEMPERATURE_LOGS, + DEVIATIONS, + CHECKLISTS +} diff --git a/backend/src/test/java/backend/fullstack/export/ExportServiceTest.java b/backend/src/test/java/backend/fullstack/export/ExportServiceTest.java new file mode 100644 index 0000000..e51152a --- /dev/null +++ b/backend/src/test/java/backend/fullstack/export/ExportServiceTest.java @@ -0,0 +1,159 @@ +package backend.fullstack.export; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.application.ExportService; +import backend.fullstack.export.application.JsonExportGenerator; +import backend.fullstack.export.application.PdfExportGenerator; +import backend.fullstack.export.domain.ExportFormat; +import backend.fullstack.export.domain.ExportModule; +import backend.fullstack.temperature.domain.TemperatureReading; +import backend.fullstack.temperature.infrastructure.TemperatureReadingRepository; + +@ExtendWith(MockitoExtension.class) +class ExportServiceTest { + + @Mock private TemperatureReadingRepository temperatureReadingRepository; + @Mock private DeviationRepository deviationRepository; + @Mock private ChecklistInstanceRepository checklistInstanceRepository; + @Mock private PdfExportGenerator pdfGenerator; + @Mock private JsonExportGenerator jsonGenerator; + + private ExportService exportService; + + @BeforeEach + void setUp() { + exportService = new ExportService( + temperatureReadingRepository, + deviationRepository, + checklistInstanceRepository, + pdfGenerator, + jsonGenerator + ); + } + + @Test + void export_temperatureLogs_json_delegatesToJsonGenerator() { + Long orgId = 1L; + LocalDate from = LocalDate.of(2026, 1, 1); + LocalDate to = LocalDate.of(2026, 1, 31); + List readings = List.of(new TemperatureReading()); + byte[] expected = "{\"test\": true}".getBytes(); + + when(temperatureReadingRepository.findForStatsByOrganizationAndRange( + eq(orgId), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(readings); + when(jsonGenerator.generateTemperatureLogsJson(readings)).thenReturn(expected); + + byte[] result = exportService.export(orgId, ExportModule.TEMPERATURE_LOGS, ExportFormat.JSON, from, to); + + assertThat(result).isEqualTo(expected); + verify(jsonGenerator).generateTemperatureLogsJson(readings); + } + + @Test + void export_temperatureLogs_pdf_delegatesToPdfGenerator() { + Long orgId = 1L; + List readings = List.of(); + byte[] expected = new byte[]{0x25, 0x50, 0x44, 0x46}; + + when(temperatureReadingRepository.findForStatsByOrganizationAndRange(eq(orgId), any(), any())) + .thenReturn(readings); + when(pdfGenerator.generateTemperatureLogsPdf(eq(readings), any(), any())).thenReturn(expected); + + byte[] result = exportService.export(orgId, ExportModule.TEMPERATURE_LOGS, ExportFormat.PDF, null, null); + + assertThat(result).isEqualTo(expected); + verify(pdfGenerator).generateTemperatureLogsPdf(eq(readings), any(), any()); + } + + @Test + void export_deviations_json_delegatesToJsonGenerator() { + Long orgId = 2L; + List deviations = List.of(); + byte[] expected = "[]".getBytes(); + + when(deviationRepository.findByOrganization_IdOrderByCreatedAtDesc(orgId)).thenReturn(deviations); + when(jsonGenerator.generateDeviationsJson(deviations)).thenReturn(expected); + + byte[] result = exportService.export(orgId, ExportModule.DEVIATIONS, ExportFormat.JSON, null, null); + + assertThat(result).isEqualTo(expected); + } + + @Test + void export_deviations_pdf_delegatesToPdfGenerator() { + Long orgId = 2L; + List deviations = List.of(); + byte[] expected = new byte[]{1, 2, 3}; + + when(deviationRepository.findByOrganization_IdOrderByCreatedAtDesc(orgId)).thenReturn(deviations); + when(pdfGenerator.generateDeviationsPdf(deviations)).thenReturn(expected); + + byte[] result = exportService.export(orgId, ExportModule.DEVIATIONS, ExportFormat.PDF, null, null); + + assertThat(result).isEqualTo(expected); + } + + @Test + void export_checklists_json_delegatesToJsonGenerator() { + Long orgId = 3L; + List checklists = List.of(); + byte[] expected = "{}".getBytes(); + + when(checklistInstanceRepository.findAllByOrganizationId(orgId)).thenReturn(checklists); + when(jsonGenerator.generateChecklistsJson(checklists)).thenReturn(expected); + + byte[] result = exportService.export(orgId, ExportModule.CHECKLISTS, ExportFormat.JSON, null, null); + + assertThat(result).isEqualTo(expected); + } + + @Test + void export_checklists_pdf_delegatesToPdfGenerator() { + Long orgId = 3L; + List checklists = List.of(); + byte[] expected = new byte[]{4, 5, 6}; + + when(checklistInstanceRepository.findAllByOrganizationId(orgId)).thenReturn(checklists); + when(pdfGenerator.generateChecklistsPdf(checklists)).thenReturn(expected); + + byte[] result = exportService.export(orgId, ExportModule.CHECKLISTS, ExportFormat.PDF, null, null); + + assertThat(result).isEqualTo(expected); + } + + @Test + void buildFilename_pdf_format() { + String filename = exportService.buildFilename(ExportModule.TEMPERATURE_LOGS, ExportFormat.PDF); + + assertThat(filename).startsWith("temperature-logs-export-"); + assertThat(filename).endsWith(".pdf"); + } + + @Test + void buildFilename_json_format() { + String filename = exportService.buildFilename(ExportModule.DEVIATIONS, ExportFormat.JSON); + + assertThat(filename).startsWith("deviations-export-"); + assertThat(filename).endsWith(".json"); + } +} diff --git a/backend/src/test/java/backend/fullstack/export/JsonExportGeneratorTest.java b/backend/src/test/java/backend/fullstack/export/JsonExportGeneratorTest.java new file mode 100644 index 0000000..0f6f2c7 --- /dev/null +++ b/backend/src/test/java/backend/fullstack/export/JsonExportGeneratorTest.java @@ -0,0 +1,108 @@ +package backend.fullstack.export; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import backend.fullstack.checklist.domain.ChecklistFrequency; +import backend.fullstack.checklist.domain.ChecklistInstance; +import backend.fullstack.checklist.domain.ChecklistInstanceItem; +import backend.fullstack.checklist.domain.ChecklistInstanceStatus; +import backend.fullstack.deviations.domain.Deviation; +import backend.fullstack.deviations.domain.DeviationModuleType; +import backend.fullstack.deviations.domain.DeviationSeverity; +import backend.fullstack.deviations.domain.DeviationStatus; +import backend.fullstack.export.application.JsonExportGenerator; +import backend.fullstack.temperature.domain.TemperatureReading; +import backend.fullstack.units.domain.TemperatureUnit; + +class JsonExportGeneratorTest { + + private JsonExportGenerator generator; + + @BeforeEach + void setUp() { + generator = new JsonExportGenerator(); + } + + @Test + void generateTemperatureLogsJson_returnsValidJsonWithData() { + TemperatureUnit unit = new TemperatureUnit(); + unit.setName("Kjøleskap 1"); + + TemperatureReading reading = TemperatureReading.builder() + .id(1L) + .temperature(4.5) + .unit(unit) + .recordedAt(LocalDateTime.of(2026, 4, 1, 10, 30)) + .build(); + + byte[] result = generator.generateTemperatureLogsJson(List.of(reading)); + String json = new String(result, StandardCharsets.UTF_8); + + assertThat(json).contains("\"exportType\" : \"TEMPERATURE_LOGS\""); + assertThat(json).contains("\"totalRecords\" : 1"); + assertThat(json).contains("\"temperature\" : 4.5"); + assertThat(json).contains("\"unitName\" : \"Kjøleskap 1\""); + } + + @Test + void generateTemperatureLogsJson_emptyList_returnsZeroRecords() { + byte[] result = generator.generateTemperatureLogsJson(List.of()); + String json = new String(result, StandardCharsets.UTF_8); + + assertThat(json).contains("\"totalRecords\" : 0"); + assertThat(json).contains("\"data\" : [ ]"); + } + + @Test + void generateDeviationsJson_returnsValidJson() { + Deviation deviation = Deviation.builder() + .id(10L) + .title("Temperature too high") + .description("Fridge was above 8°C") + .status(DeviationStatus.OPEN) + .severity(DeviationSeverity.HIGH) + .moduleType(DeviationModuleType.IK_MAT) + .createdAt(LocalDateTime.of(2026, 3, 15, 14, 0)) + .build(); + + byte[] result = generator.generateDeviationsJson(List.of(deviation)); + String json = new String(result, StandardCharsets.UTF_8); + + assertThat(json).contains("\"exportType\" : \"DEVIATIONS\""); + assertThat(json).contains("\"title\" : \"Temperature too high\""); + assertThat(json).contains("\"status\" : \"OPEN\""); + assertThat(json).contains("\"severity\" : \"HIGH\""); + } + + @Test + void generateChecklistsJson_includesItems() { + ChecklistInstance checklist = new ChecklistInstance(); + checklist.setId(5L); + checklist.setTitle("Morning checklist"); + checklist.setFrequency(ChecklistFrequency.DAILY); + checklist.setDate(LocalDate.of(2026, 4, 1)); + checklist.setStatus(ChecklistInstanceStatus.COMPLETED); + + ChecklistInstanceItem item = new ChecklistInstanceItem(); + item.setId(100L); + item.setText("Check fridge temperature"); + item.setCompleted(true); + checklist.addItem(item); + + byte[] result = generator.generateChecklistsJson(List.of(checklist)); + String json = new String(result, StandardCharsets.UTF_8); + + assertThat(json).contains("\"exportType\" : \"CHECKLISTS\""); + assertThat(json).contains("\"title\" : \"Morning checklist\""); + assertThat(json).contains("\"text\" : \"Check fridge temperature\""); + assertThat(json).contains("\"completed\" : true"); + } +} diff --git a/backend/src/test/java/backend/fullstack/export/PdfExportGeneratorTest.java b/backend/src/test/java/backend/fullstack/export/PdfExportGeneratorTest.java new file mode 100644 index 0000000..066e3da --- /dev/null +++ b/backend/src/test/java/backend/fullstack/export/PdfExportGeneratorTest.java @@ -0,0 +1,123 @@ +package backend.fullstack.export; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import backend.fullstack.checklist.domain.ChecklistFrequency; +import backend.fullstack.checklist.domain.ChecklistInstance; +import backend.fullstack.checklist.domain.ChecklistInstanceItem; +import backend.fullstack.checklist.domain.ChecklistInstanceStatus; +import backend.fullstack.deviations.domain.Deviation; +import backend.fullstack.deviations.domain.DeviationModuleType; +import backend.fullstack.deviations.domain.DeviationSeverity; +import backend.fullstack.deviations.domain.DeviationStatus; +import backend.fullstack.export.application.PdfExportGenerator; +import backend.fullstack.temperature.domain.TemperatureReading; +import backend.fullstack.units.domain.TemperatureUnit; +import backend.fullstack.user.User; + +class PdfExportGeneratorTest { + + private PdfExportGenerator generator; + + @BeforeEach + void setUp() { + generator = new PdfExportGenerator(); + } + + @Test + void generateTemperatureLogsPdf_returnsValidPdf() { + TemperatureUnit unit = new TemperatureUnit(); + unit.setName("Fryser 1"); + + User user = new User(); + user.setFirstName("Ola"); + user.setLastName("Nordmann"); + + TemperatureReading reading = TemperatureReading.builder() + .id(1L) + .temperature(-18.0) + .unit(unit) + .recordedBy(user) + .recordedAt(LocalDateTime.of(2026, 4, 1, 8, 0)) + .build(); + + byte[] result = generator.generateTemperatureLogsPdf( + List.of(reading), + LocalDate.of(2026, 4, 1), + LocalDate.of(2026, 4, 7) + ); + + assertThat(result).isNotEmpty(); + assertThat(new String(result, 0, 5)).isEqualTo("%PDF-"); + } + + @Test + void generateTemperatureLogsPdf_emptyList_producesValidPdf() { + byte[] result = generator.generateTemperatureLogsPdf(List.of(), null, null); + + assertThat(result).isNotEmpty(); + assertThat(new String(result, 0, 5)).isEqualTo("%PDF-"); + } + + @Test + void generateDeviationsPdf_returnsValidPdf() { + Deviation deviation = Deviation.builder() + .id(5L) + .title("Power outage") + .description("Electricity was off for 2 hours") + .status(DeviationStatus.OPEN) + .severity(DeviationSeverity.CRITICAL) + .moduleType(DeviationModuleType.IK_MAT) + .createdAt(LocalDateTime.of(2026, 3, 20, 16, 45)) + .build(); + + byte[] result = generator.generateDeviationsPdf(List.of(deviation)); + + assertThat(result).isNotEmpty(); + assertThat(new String(result, 0, 5)).isEqualTo("%PDF-"); + } + + @Test + void generateDeviationsPdf_emptyList_producesValidPdf() { + byte[] result = generator.generateDeviationsPdf(List.of()); + + assertThat(result).isNotEmpty(); + assertThat(new String(result, 0, 5)).isEqualTo("%PDF-"); + } + + @Test + void generateChecklistsPdf_returnsValidPdf() { + ChecklistInstance checklist = new ChecklistInstance(); + checklist.setId(1L); + checklist.setTitle("Daglig sjekkliste"); + checklist.setFrequency(ChecklistFrequency.DAILY); + checklist.setDate(LocalDate.of(2026, 4, 1)); + checklist.setStatus(ChecklistInstanceStatus.COMPLETED); + + ChecklistInstanceItem item = new ChecklistInstanceItem(); + item.setId(10L); + item.setText("Sjekk temperatur"); + item.setCompleted(true); + checklist.addItem(item); + + byte[] result = generator.generateChecklistsPdf(List.of(checklist)); + + assertThat(result).isNotEmpty(); + assertThat(new String(result, 0, 5)).isEqualTo("%PDF-"); + } + + @Test + void generateChecklistsPdf_emptyList_producesValidPdf() { + byte[] result = generator.generateChecklistsPdf(List.of()); + + assertThat(result).isNotEmpty(); + assertThat(new String(result, 0, 5)).isEqualTo("%PDF-"); + } +}