Skip to content

Commit 8560f58

Browse files
authored
Development: Refactor review page (#596)
* adapt evaluation detail page and add custom components * download all documents in evaluation server module * generate openAPI code for download all documents * add translation and pipes * adapt link styling * add toastService to document-section.ts * fix translation * only show present links * fixe comments * run prettier * add job title to zip name and adjust applicant name * remove custom pipes and use translate pipe
1 parent 87d5ab1 commit 8560f58

36 files changed

Lines changed: 1319 additions & 45 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
meta {
2+
name: Download All Documents
3+
type: http
4+
seq: 1
5+
}
6+
7+
get {
8+
url: {{baseUrl}}/api/evaluation/applications/:applicationId/documents-download
9+
body: none
10+
auth: inherit
11+
}
12+
13+
params:path {
14+
applicationId:
15+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
meta {
2+
name: Documents
3+
seq: 3
4+
}
5+
6+
auth {
7+
mode: inherit
8+
}

openapi/openapi.yaml

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -634,7 +634,7 @@
634634
}
635635
}
636636
},
637-
"/api/evaluation/applications({applicationId}/reject": {
637+
"/api/evaluation/applications/{applicationId}/reject": {
638638
"post": {
639639
"tags": [
640640
"application-evaluation-resource"
@@ -668,7 +668,7 @@
668668
}
669669
}
670670
},
671-
"/api/evaluation/applications({applicationId}/accept": {
671+
"/api/evaluation/applications/{applicationId}/accept": {
672672
"post": {
673673
"tags": [
674674
"application-evaluation-resource"
@@ -1902,6 +1902,38 @@
19021902
}
19031903
}
19041904
},
1905+
"/api/evaluation/applications/{applicationId}/documents-download": {
1906+
"get": {
1907+
"tags": [
1908+
"application-evaluation-resource"
1909+
],
1910+
"operationId": "downloadAll",
1911+
"parameters": [
1912+
{
1913+
"name": "applicationId",
1914+
"in": "path",
1915+
"required": true,
1916+
"schema": {
1917+
"type": "string",
1918+
"format": "uuid"
1919+
}
1920+
}
1921+
],
1922+
"responses": {
1923+
"200": {
1924+
"description": "ZIP file containing all documents",
1925+
"content": {
1926+
"application/zip": {
1927+
"schema": {
1928+
"type": "string",
1929+
"format": "binary"
1930+
}
1931+
}
1932+
}
1933+
}
1934+
}
1935+
}
1936+
},
19051937
"/api/evaluation/application-details": {
19061938
"get": {
19071939
"tags": [
@@ -3306,10 +3338,10 @@
33063338
"empty": {
33073339
"type": "boolean"
33083340
},
3309-
"unsorted": {
3341+
"sorted": {
33103342
"type": "boolean"
33113343
},
3312-
"sorted": {
3344+
"unsorted": {
33133345
"type": "boolean"
33143346
}
33153347
}
@@ -4326,4 +4358,4 @@
43264358
}
43274359
}
43284360
}
4329-
}
4361+
}

src/main/java/de/tum/cit/aet/core/repository/DocumentDictionaryRepository.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
import de.tum.cit.aet.core.constants.DocumentType;
66
import de.tum.cit.aet.core.domain.DocumentDictionary;
77
import de.tum.cit.aet.usermanagement.domain.Applicant;
8+
import org.springframework.stereotype.Repository;
9+
810
import java.util.Set;
911
import java.util.UUID;
10-
import org.springframework.stereotype.Repository;
1112

1213
@Repository
1314
public interface DocumentDictionaryRepository extends TumApplyJpaRepository<DocumentDictionary, UUID>, DocumentDictionaryEntityRepository {
@@ -16,4 +17,6 @@ public interface DocumentDictionaryRepository extends TumApplyJpaRepository<Docu
1617
Set<DocumentDictionary> findByApplicationAndDocumentType(Application application, DocumentType documentType);
1718

1819
Set<DocumentDictionary> findByCustomFieldAnswer(CustomFieldAnswer customFieldAnswer);
20+
21+
Set<DocumentDictionary> findAllByApplicationApplicationId(UUID applicationId);
1922
}

src/main/java/de/tum/cit/aet/core/service/DocumentDictionaryService.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
import de.tum.cit.aet.core.repository.DocumentDictionaryRepository;
1111
import de.tum.cit.aet.core.service.support.DocumentDictionaryOwnerSetter;
1212
import de.tum.cit.aet.usermanagement.domain.Applicant;
13-
import java.util.Set;
14-
import java.util.UUID;
15-
import java.util.stream.Collectors;
1613
import lombok.AllArgsConstructor;
1714
import org.springframework.data.util.Pair;
1815
import org.springframework.stereotype.Service;
1916

17+
import java.util.Set;
18+
import java.util.UUID;
19+
import java.util.stream.Collectors;
20+
2021
@Service
2122
@AllArgsConstructor
2223
public class DocumentDictionaryService {
@@ -181,4 +182,13 @@ public void renameDocument(UUID documentDictionaryId, String newName) {
181182
documentDictionaryRepository.save(document);
182183
});
183184
}
185+
186+
/**
187+
* Get all {@link DocumentDictionary} entries for an application
188+
* @param applicationId the id of the application
189+
* @return a {@link Set} of {@link DocumentDictionary}
190+
*/
191+
public Set<DocumentDictionary> findAllByApplication(UUID applicationId) {
192+
return documentDictionaryRepository.findAllByApplicationApplicationId(applicationId);
193+
}
184194
}

src/main/java/de/tum/cit/aet/core/service/DocumentService.java

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@
55
import de.tum.cit.aet.core.exception.UploadException;
66
import de.tum.cit.aet.core.repository.DocumentRepository;
77
import de.tum.cit.aet.usermanagement.domain.User;
8+
import lombok.NonNull;
9+
import org.apache.commons.io.FilenameUtils;
10+
import org.springframework.beans.factory.annotation.Value;
11+
import org.springframework.core.io.PathResource;
12+
import org.springframework.core.io.Resource;
13+
import org.springframework.stereotype.Service;
14+
import org.springframework.util.StringUtils;
15+
import org.springframework.web.multipart.MultipartFile;
16+
817
import java.io.IOException;
918
import java.io.InputStream;
1019
import java.io.OutputStream;
@@ -18,13 +27,6 @@
1827
import java.util.EnumSet;
1928
import java.util.HexFormat;
2029
import java.util.Optional;
21-
import org.apache.commons.io.FilenameUtils;
22-
import org.springframework.beans.factory.annotation.Value;
23-
import org.springframework.core.io.PathResource;
24-
import org.springframework.core.io.Resource;
25-
import org.springframework.stereotype.Service;
26-
import org.springframework.util.StringUtils;
27-
import org.springframework.web.multipart.MultipartFile;
2830

2931
@Service
3032
public class DocumentService {
@@ -128,6 +130,24 @@ public Resource download(Document document) {
128130
}
129131
}
130132

133+
/**
134+
* Resolves the file extension for the given document.
135+
*
136+
* @param document the document
137+
* @return the corresponding file extension (e.g. ".pdf"), or an empty string if unsupported
138+
*/
139+
public FileExtension resolveFileExtension(@NonNull Document document) {
140+
String mimeType = document.getMimeType();
141+
if (mimeType == null) {
142+
throw new IllegalArgumentException("Document must have a mime type");
143+
}
144+
145+
return switch (mimeType) {
146+
case "application/pdf" -> FileExtension.PDF;
147+
default -> throw new IllegalArgumentException("Unsupported mime type: " + mimeType);
148+
};
149+
}
150+
131151
/**
132152
* Return a single document as a {@link Resource} that the caller can
133153
* stream or copy. The SHA-256 (primary key) is the lookup key.

src/main/java/de/tum/cit/aet/evaluation/service/ApplicationEvaluationService.java

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@
22

33
import de.tum.cit.aet.application.constants.ApplicationState;
44
import de.tum.cit.aet.application.domain.Application;
5+
import de.tum.cit.aet.core.constants.DocumentType;
56
import de.tum.cit.aet.core.constants.Language;
7+
import de.tum.cit.aet.core.domain.Document;
8+
import de.tum.cit.aet.core.domain.DocumentDictionary;
69
import de.tum.cit.aet.core.dto.OffsetPageDTO;
710
import de.tum.cit.aet.core.dto.SortDTO;
811
import de.tum.cit.aet.core.exception.EntityNotFoundException;
12+
import de.tum.cit.aet.core.service.DocumentDictionaryService;
13+
import de.tum.cit.aet.core.service.DocumentService;
914
import de.tum.cit.aet.core.util.OffsetPageRequest;
1015
import de.tum.cit.aet.evaluation.domain.ApplicationReview;
1116
import de.tum.cit.aet.evaluation.dto.*;
@@ -20,25 +25,36 @@
2025
import de.tum.cit.aet.usermanagement.domain.Applicant;
2126
import de.tum.cit.aet.usermanagement.domain.ResearchGroup;
2227
import de.tum.cit.aet.usermanagement.domain.User;
23-
import lombok.AllArgsConstructor;
28+
import jakarta.servlet.http.HttpServletResponse;
2429
import lombok.NonNull;
30+
import lombok.RequiredArgsConstructor;
31+
import org.springframework.beans.factory.annotation.Value;
2532
import org.springframework.data.domain.Pageable;
2633
import org.springframework.stereotype.Service;
2734

28-
import java.util.List;
29-
import java.util.Map;
30-
import java.util.Set;
31-
import java.util.UUID;
35+
import java.io.BufferedOutputStream;
36+
import java.io.IOException;
37+
import java.util.*;
38+
import java.util.stream.Collectors;
39+
import java.util.zip.Deflater;
40+
import java.util.zip.ZipEntry;
41+
import java.util.zip.ZipOutputStream;
3242

3343
@Service
34-
@AllArgsConstructor
44+
@RequiredArgsConstructor
3545
public class ApplicationEvaluationService {
3646

3747
private final JobService jobService;
48+
private final DocumentDictionaryService documentDictionaryService;
49+
private final DocumentService documentService;
3850
private final AsyncEmailSender sender;
3951
private final ApplicationEvaluationRepository applicationEvaluationRepository;
4052
private final JobEvaluationRepository jobEvaluationRepository;
4153

54+
@Value("${aet.download.deterministic-zip:false}")
55+
private boolean DETERMINISTIC_ZIP;
56+
57+
4258
private static final Set<ApplicationState> VIEWABLE_STATES = Set.of(
4359
ApplicationState.SENT,
4460
ApplicationState.IN_REVIEW,
@@ -267,6 +283,116 @@ public void markApplicationAsInReview(UUID applicationId) {
267283
applicationEvaluationRepository.markApplicationAsInReview(applicationId);
268284
}
269285

286+
/**
287+
* Collects all documents belonging to the specified application and streams them
288+
* as a single ZIP file to the HTTP response output stream. The ZIP file name is
289+
* based on the applicant's first and last name.
290+
*
291+
* @param applicationId the ID of the application whose documents are downloaded
292+
* @param response the HTTP response used to write the ZIP content
293+
* @throws IOException if an I/O error occurs while writing to the response
294+
*/
295+
public void downloadAllDocumentsForApplication(UUID applicationId, HttpServletResponse response) throws IOException {
296+
Application application = getApplication(applicationId);
297+
Set<DocumentDictionary> documentDictionaries = documentDictionaryService.findAllByApplication(applicationId);
298+
299+
User user = application.getApplicant().getUser();
300+
String zipName = sanitizeFilename(user.getFirstName() + " " + user.getLastName() + " - " + application.getJob().getTitle());
301+
302+
response.setStatus(HttpServletResponse.SC_OK);
303+
response.setContentType("application/zip");
304+
response.setHeader("Content-Disposition", String.format("attachment; filename=\"%s.zip\"", zipName));
305+
306+
// Count per type to decide if numbering is needed
307+
Map<DocumentType, Long> typeCounts = documentDictionaries.stream()
308+
.collect(Collectors.groupingBy(
309+
DocumentDictionary::getDocumentType,
310+
() -> new EnumMap<>(DocumentType.class),
311+
Collectors.counting()
312+
));
313+
314+
// Per-type index used only when count > 1
315+
Map<DocumentType, Integer> typeIndex = new EnumMap<>(DocumentType.class);
316+
317+
try (BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream());
318+
ZipOutputStream zos = new ZipOutputStream(bos)) {
319+
320+
zos.setLevel(Deflater.BEST_SPEED);
321+
322+
for (DocumentDictionary dd : documentDictionaries) {
323+
DocumentType type = dd.getDocumentType();
324+
Document doc = dd.getDocument();
325+
326+
long count = typeCounts.getOrDefault(type, 0L);
327+
String base = fileName(type);
328+
String entryName = (count > 1)
329+
? base + "_" + typeIndex.merge(type, 1, Integer::sum)
330+
: base;
331+
332+
// append file extension if present (e.g., "cv.pdf")
333+
String ext = documentService.resolveFileExtension(doc).getExtension();
334+
if (ext != null && !ext.isBlank()) {
335+
entryName += "." + ext;
336+
}
337+
338+
ZipEntry entry = new ZipEntry(entryName);
339+
zos.putNextEntry(entry);
340+
341+
// can be enabled for testing
342+
if (DETERMINISTIC_ZIP) {
343+
entry.setTime(0L);
344+
}
345+
346+
byte[] bytes = documentService.download(doc).getContentAsByteArray();
347+
zos.write(bytes);
348+
349+
zos.closeEntry();
350+
}
351+
352+
zos.finish();
353+
}
354+
}
355+
356+
private static String sanitizeFilename(String input) {
357+
if (input == null || input.isBlank()) {
358+
return "file";
359+
}
360+
String cleaned = input.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]+", "_").trim();
361+
362+
// limit length (some file systems break >255 chars)
363+
if (cleaned.length() > 120) {
364+
cleaned = cleaned.substring(0, 120);
365+
}
366+
367+
return cleaned.isEmpty() ? "file" : cleaned;
368+
}
369+
370+
/**
371+
* Resolves a file name prefix for the given document type.
372+
*
373+
* @param documentType the type of document
374+
* @return a short, lowercase file name string corresponding to the document type
375+
*/
376+
private String fileName(DocumentType documentType) {
377+
return switch (documentType) {
378+
case BACHELOR_TRANSCRIPT -> "bachelor";
379+
case MASTER_TRANSCRIPT -> "master";
380+
case CV -> "cv";
381+
case REFERENCE -> "reference";
382+
case CUSTOM -> "custom";
383+
};
384+
}
385+
386+
/**
387+
* Get the application for an applicationId
388+
*
389+
* @param applicationId the id of the application
390+
* @return {@link Application}
391+
*/
392+
public Application getApplication(UUID applicationId) {
393+
return applicationEvaluationRepository.findById(applicationId).orElseThrow(() -> EntityNotFoundException.forId("Application", applicationId));
394+
}
395+
270396
/**
271397
* Helper method to retrieve a paginated list of applications in viewable states for the specified research group,
272398
* applying optional dynamic filters.

0 commit comments

Comments
 (0)