Skip to content

Commit b18f779

Browse files
Merge pull request #36 from Stcwal/backend/export-service
Add export functionality (PDF & JSON)
2 parents 8d159fe + 82fb3a6 commit b18f779

File tree

11 files changed

+1038
-0
lines changed

11 files changed

+1038
-0
lines changed

backend/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,13 @@
152152
<scope>provided</scope>
153153
</dependency>
154154

155+
<!-- OpenPDF for PDF generation -->
156+
<dependency>
157+
<groupId>com.github.librepdf</groupId>
158+
<artifactId>openpdf</artifactId>
159+
<version>2.0.3</version>
160+
</dependency>
161+
155162
<dependency>
156163
<groupId>org.projectlombok</groupId>
157164
<artifactId>lombok-mapstruct-binding</artifactId>
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package backend.fullstack.export.api;
2+
3+
import org.springframework.http.ContentDisposition;
4+
import org.springframework.http.HttpHeaders;
5+
import org.springframework.http.MediaType;
6+
import org.springframework.http.ResponseEntity;
7+
import org.springframework.security.access.prepost.PreAuthorize;
8+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
9+
import org.springframework.web.bind.annotation.PostMapping;
10+
import org.springframework.web.bind.annotation.RequestBody;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RestController;
13+
14+
import backend.fullstack.config.JwtPrincipal;
15+
import backend.fullstack.export.api.dto.ExportRequest;
16+
import backend.fullstack.export.application.ExportService;
17+
import backend.fullstack.export.domain.ExportFormat;
18+
import io.swagger.v3.oas.annotations.Operation;
19+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
20+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
21+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
22+
import io.swagger.v3.oas.annotations.tags.Tag;
23+
import jakarta.validation.Valid;
24+
25+
/**
26+
* REST controller for exporting data as PDF or JSON.
27+
*/
28+
@RestController
29+
@RequestMapping("/api/export")
30+
@Tag(name = "Export", description = "Data export in PDF and JSON formats")
31+
@SecurityRequirement(name = "Bearer Auth")
32+
public class ExportController {
33+
34+
private final ExportService exportService;
35+
36+
public ExportController(ExportService exportService) {
37+
this.exportService = exportService;
38+
}
39+
40+
@PostMapping
41+
@PreAuthorize("hasAnyRole('ADMIN','MANAGER','SUPERVISOR')")
42+
@Operation(
43+
summary = "Export data",
44+
description = "Exports data from the specified module in PDF or JSON format. "
45+
+ "Supports optional date range filtering."
46+
)
47+
@ApiResponses(value = {
48+
@ApiResponse(responseCode = "200", description = "Export generated successfully"),
49+
@ApiResponse(responseCode = "400", description = "Invalid request parameters"),
50+
@ApiResponse(responseCode = "401", description = "Unauthorized"),
51+
@ApiResponse(responseCode = "403", description = "Forbidden — missing REPORTS_EXPORT permission")
52+
})
53+
public ResponseEntity<byte[]> exportData(
54+
@AuthenticationPrincipal JwtPrincipal principal,
55+
@Valid @RequestBody ExportRequest request
56+
) {
57+
byte[] data = exportService.export(
58+
principal.organizationId(),
59+
request.module(),
60+
request.format(),
61+
request.from(),
62+
request.to()
63+
);
64+
65+
String filename = exportService.buildFilename(request.module(), request.format());
66+
MediaType mediaType = request.format() == ExportFormat.PDF
67+
? MediaType.APPLICATION_PDF
68+
: MediaType.APPLICATION_JSON;
69+
70+
HttpHeaders headers = new HttpHeaders();
71+
headers.setContentType(mediaType);
72+
headers.setContentDisposition(
73+
ContentDisposition.attachment().filename(filename).build()
74+
);
75+
76+
return ResponseEntity.ok().headers(headers).body(data);
77+
}
78+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package backend.fullstack.export.api.dto;
2+
3+
import java.time.LocalDate;
4+
5+
import backend.fullstack.export.domain.ExportFormat;
6+
import backend.fullstack.export.domain.ExportModule;
7+
import jakarta.validation.constraints.NotNull;
8+
9+
/**
10+
* Request body for data export.
11+
*
12+
* @param module the data module to export
13+
* @param format desired output format (PDF or JSON)
14+
* @param from optional start date filter (inclusive)
15+
* @param to optional end date filter (inclusive)
16+
*/
17+
public record ExportRequest(
18+
@NotNull(message = "Module is required")
19+
ExportModule module,
20+
21+
@NotNull(message = "Format is required")
22+
ExportFormat format,
23+
24+
LocalDate from,
25+
26+
LocalDate to
27+
) {}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package backend.fullstack.export.application;
2+
3+
import java.time.LocalDate;
4+
import java.time.LocalDateTime;
5+
import java.time.format.DateTimeFormatter;
6+
import java.util.List;
7+
8+
import org.springframework.stereotype.Service;
9+
import org.springframework.transaction.annotation.Transactional;
10+
11+
import backend.fullstack.checklist.domain.ChecklistInstance;
12+
import backend.fullstack.checklist.infrastructure.ChecklistInstanceRepository;
13+
import backend.fullstack.deviations.domain.Deviation;
14+
import backend.fullstack.deviations.infrastructure.DeviationRepository;
15+
import backend.fullstack.export.domain.ExportFormat;
16+
import backend.fullstack.export.domain.ExportModule;
17+
import backend.fullstack.temperature.domain.TemperatureReading;
18+
import backend.fullstack.temperature.infrastructure.TemperatureReadingRepository;
19+
20+
/**
21+
* Service responsible for orchestrating data exports.
22+
* Fetches data from the relevant module and delegates to format-specific generators.
23+
*/
24+
@Service
25+
@Transactional(readOnly = true)
26+
public class ExportService {
27+
28+
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
29+
30+
private final TemperatureReadingRepository temperatureReadingRepository;
31+
private final DeviationRepository deviationRepository;
32+
private final ChecklistInstanceRepository checklistInstanceRepository;
33+
private final PdfExportGenerator pdfGenerator;
34+
private final JsonExportGenerator jsonGenerator;
35+
36+
public ExportService(
37+
TemperatureReadingRepository temperatureReadingRepository,
38+
DeviationRepository deviationRepository,
39+
ChecklistInstanceRepository checklistInstanceRepository,
40+
PdfExportGenerator pdfGenerator,
41+
JsonExportGenerator jsonGenerator
42+
) {
43+
this.temperatureReadingRepository = temperatureReadingRepository;
44+
this.deviationRepository = deviationRepository;
45+
this.checklistInstanceRepository = checklistInstanceRepository;
46+
this.pdfGenerator = pdfGenerator;
47+
this.jsonGenerator = jsonGenerator;
48+
}
49+
50+
/**
51+
* Exports data for the given module and format.
52+
*
53+
* @param organizationId the organization scope
54+
* @param module the data module to export
55+
* @param format desired output format
56+
* @param from optional start date (inclusive)
57+
* @param to optional end date (inclusive)
58+
* @return byte array containing the exported file
59+
*/
60+
public byte[] export(Long organizationId, ExportModule module, ExportFormat format,
61+
LocalDate from, LocalDate to) {
62+
return switch (module) {
63+
case TEMPERATURE_LOGS -> exportTemperatureLogs(organizationId, format, from, to);
64+
case DEVIATIONS -> exportDeviations(organizationId, format);
65+
case CHECKLISTS -> exportChecklists(organizationId, format);
66+
};
67+
}
68+
69+
/**
70+
* Builds a descriptive filename for the export.
71+
*/
72+
public String buildFilename(ExportModule module, ExportFormat format) {
73+
String moduleName = module.name().toLowerCase().replace('_', '-');
74+
String date = LocalDate.now().format(DATE_FMT);
75+
String extension = format == ExportFormat.PDF ? "pdf" : "json";
76+
return moduleName + "-export-" + date + "." + extension;
77+
}
78+
79+
private byte[] exportTemperatureLogs(Long organizationId, ExportFormat format,
80+
LocalDate from, LocalDate to) {
81+
LocalDateTime fromDt = from != null ? from.atStartOfDay() : null;
82+
LocalDateTime toDt = to != null ? to.atTime(23, 59, 59) : null;
83+
84+
List<TemperatureReading> readings = temperatureReadingRepository
85+
.findForStatsByOrganizationAndRange(organizationId, fromDt, toDt);
86+
87+
return format == ExportFormat.PDF
88+
? pdfGenerator.generateTemperatureLogsPdf(readings, from, to)
89+
: jsonGenerator.generateTemperatureLogsJson(readings);
90+
}
91+
92+
private byte[] exportDeviations(Long organizationId, ExportFormat format) {
93+
List<Deviation> deviations = deviationRepository
94+
.findByOrganization_IdOrderByCreatedAtDesc(organizationId);
95+
96+
return format == ExportFormat.PDF
97+
? pdfGenerator.generateDeviationsPdf(deviations)
98+
: jsonGenerator.generateDeviationsJson(deviations);
99+
}
100+
101+
private byte[] exportChecklists(Long organizationId, ExportFormat format) {
102+
List<ChecklistInstance> checklists = checklistInstanceRepository
103+
.findAllByOrganizationId(organizationId);
104+
105+
return format == ExportFormat.PDF
106+
? pdfGenerator.generateChecklistsPdf(checklists)
107+
: jsonGenerator.generateChecklistsJson(checklists);
108+
}
109+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package backend.fullstack.export.application;
2+
3+
import java.nio.charset.StandardCharsets;
4+
import java.time.LocalDate;
5+
import java.time.LocalDateTime;
6+
import java.time.format.DateTimeFormatter;
7+
import java.util.LinkedHashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
11+
import org.springframework.stereotype.Component;
12+
13+
import com.fasterxml.jackson.core.JsonProcessingException;
14+
import com.fasterxml.jackson.databind.ObjectMapper;
15+
import com.fasterxml.jackson.databind.SerializationFeature;
16+
17+
import backend.fullstack.checklist.domain.ChecklistInstance;
18+
import backend.fullstack.checklist.domain.ChecklistInstanceItem;
19+
import backend.fullstack.deviations.domain.Deviation;
20+
import backend.fullstack.temperature.domain.TemperatureReading;
21+
22+
/**
23+
* Generates JSON export files from domain data.
24+
*/
25+
@Component
26+
public class JsonExportGenerator {
27+
28+
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
29+
private static final DateTimeFormatter D_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
30+
31+
private final ObjectMapper objectMapper;
32+
33+
public JsonExportGenerator() {
34+
this.objectMapper = new ObjectMapper();
35+
this.objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
36+
}
37+
38+
public byte[] generateTemperatureLogsJson(List<TemperatureReading> readings) {
39+
List<Map<String, Object>> items = readings.stream().map(r -> {
40+
Map<String, Object> map = new LinkedHashMap<>();
41+
map.put("id", r.getId());
42+
map.put("temperature", r.getTemperature());
43+
map.put("unitName", r.getUnit() != null ? r.getUnit().getName() : null);
44+
map.put("recordedAt", formatDateTime(r.getRecordedAt()));
45+
map.put("recordedBy", r.getRecordedBy() != null
46+
? r.getRecordedBy().getFirstName() + " " + r.getRecordedBy().getLastName()
47+
: null);
48+
map.put("note", r.getNote());
49+
map.put("isDeviation", r.isDeviation());
50+
return map;
51+
}).toList();
52+
53+
Map<String, Object> root = new LinkedHashMap<>();
54+
root.put("exportType", "TEMPERATURE_LOGS");
55+
root.put("exportedAt", formatDateTime(LocalDateTime.now()));
56+
root.put("totalRecords", items.size());
57+
root.put("data", items);
58+
59+
return toBytes(root);
60+
}
61+
62+
public byte[] generateDeviationsJson(List<Deviation> deviations) {
63+
List<Map<String, Object>> items = deviations.stream().map(d -> {
64+
Map<String, Object> map = new LinkedHashMap<>();
65+
map.put("id", d.getId());
66+
map.put("title", d.getTitle());
67+
map.put("description", d.getDescription());
68+
map.put("status", d.getStatus() != null ? d.getStatus().name() : null);
69+
map.put("severity", d.getSeverity() != null ? d.getSeverity().name() : null);
70+
map.put("moduleType", d.getModuleType() != null ? d.getModuleType().name() : null);
71+
map.put("reportedBy", d.getReportedByName());
72+
map.put("createdAt", formatDateTime(d.getCreatedAt()));
73+
map.put("resolvedBy", d.getResolvedByName());
74+
map.put("resolvedAt", formatDateTime(d.getResolvedAt()));
75+
map.put("resolution", d.getResolution());
76+
return map;
77+
}).toList();
78+
79+
Map<String, Object> root = new LinkedHashMap<>();
80+
root.put("exportType", "DEVIATIONS");
81+
root.put("exportedAt", formatDateTime(LocalDateTime.now()));
82+
root.put("totalRecords", items.size());
83+
root.put("data", items);
84+
85+
return toBytes(root);
86+
}
87+
88+
public byte[] generateChecklistsJson(List<ChecklistInstance> checklists) {
89+
List<Map<String, Object>> items = checklists.stream().map(c -> {
90+
Map<String, Object> map = new LinkedHashMap<>();
91+
map.put("id", c.getId());
92+
map.put("title", c.getTitle());
93+
map.put("frequency", c.getFrequency() != null ? c.getFrequency().name() : null);
94+
map.put("date", formatDate(c.getDate()));
95+
map.put("status", c.getStatus() != null ? c.getStatus().name() : null);
96+
map.put("items", c.getItems().stream().map(this::mapChecklistItem).toList());
97+
return map;
98+
}).toList();
99+
100+
Map<String, Object> root = new LinkedHashMap<>();
101+
root.put("exportType", "CHECKLISTS");
102+
root.put("exportedAt", formatDateTime(LocalDateTime.now()));
103+
root.put("totalRecords", items.size());
104+
root.put("data", items);
105+
106+
return toBytes(root);
107+
}
108+
109+
private Map<String, Object> mapChecklistItem(ChecklistInstanceItem item) {
110+
Map<String, Object> map = new LinkedHashMap<>();
111+
map.put("id", item.getId());
112+
map.put("text", item.getText());
113+
map.put("completed", item.isCompleted());
114+
map.put("completedByUserId", item.getCompletedByUserId());
115+
map.put("completedAt", item.getCompletedAt() != null ? item.getCompletedAt().toString() : null);
116+
return map;
117+
}
118+
119+
private String formatDateTime(LocalDateTime dt) {
120+
return dt != null ? dt.format(DT_FMT) : null;
121+
}
122+
123+
private String formatDate(LocalDate d) {
124+
return d != null ? d.format(D_FMT) : null;
125+
}
126+
127+
private byte[] toBytes(Object value) {
128+
try {
129+
return objectMapper.writeValueAsString(value).getBytes(StandardCharsets.UTF_8);
130+
} catch (JsonProcessingException e) {
131+
throw new IllegalStateException("Failed to generate JSON export", e);
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)