Compare commits
283 Commits
feature/68
...
676d3cb6a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
676d3cb6a7 | ||
|
|
d389dc2023 | ||
|
|
b4212f5e86 | ||
|
|
c22f2e41b1 | ||
|
|
7d2d615e0c | ||
|
|
4a88b3ba82 | ||
|
|
6dc81ef2e3 | ||
|
|
cef1810700 | ||
|
|
351f31b183 | ||
|
|
e6432846a1 | ||
|
|
a66bec1971 | ||
|
|
82d5a34f76 | ||
|
|
3d086bd1fb | ||
|
|
e384c87eef | ||
|
|
f09b605752 | ||
|
|
193bd73af1 | ||
|
|
cab017a2ce | ||
|
|
be4f1ed73b | ||
|
|
6475ebcc60 | ||
|
|
d8830b5a8e | ||
|
|
569a13e1b1 | ||
|
|
7ad852dd52 | ||
|
|
03d76863cb | ||
|
|
f3c29ffe58 | ||
|
|
8c26876345 | ||
|
|
da43cadb0a | ||
|
|
3b2d905041 | ||
|
|
7036f18b25 | ||
|
|
99e2e6e5c1 | ||
|
|
aaffee2804 | ||
|
|
18c6bca2dd | ||
|
|
d13f6f69d5 | ||
|
|
052f70e871 | ||
|
|
a3fbcf346b | ||
|
|
b21778b3d1 | ||
|
|
51c799e20e | ||
|
|
6463a32dfc | ||
|
|
1efd3d8e23 | ||
|
|
5211e0b9f7 | ||
|
|
234f83c40b | ||
|
|
a46b1a2e84 | ||
|
|
5231476c27 | ||
|
|
46d64f50a5 | ||
|
|
1a57ec2036 | ||
|
|
e362bc4977 | ||
|
|
01ba0d4121 | ||
|
|
2e6366faf7 | ||
|
|
9dd35999e0 | ||
|
|
e94f43264c | ||
|
|
da7f94de84 | ||
|
|
3f0b686963 | ||
|
|
1e9ef63191 | ||
|
|
51348ad26a | ||
|
|
dba1e2a8eb | ||
|
|
654b1283c1 | ||
|
|
c5b98af69b | ||
|
|
03e2382c8a | ||
|
|
528e1e05ea | ||
|
|
c64abccf63 | ||
|
|
47960b5028 | ||
|
|
7f2940f0f2 | ||
|
|
37d728b006 | ||
|
|
965087b787 | ||
|
|
1d2e6d7b86 | ||
|
|
0c40e10743 | ||
|
|
358131ca34 | ||
|
|
c7af33b998 | ||
|
|
eafb566170 | ||
|
|
624eb9e5d6 | ||
|
|
7bd995a045 | ||
|
|
20dbe04d45 | ||
|
|
c9211b3061 | ||
|
|
27254fb0ac | ||
|
|
b5a68e69e2 | ||
|
|
b1e959412f | ||
|
|
19035fbeab | ||
|
|
79faee554a | ||
|
|
5adef7bec5 | ||
|
|
595c2eb987 | ||
|
|
518019f099 | ||
|
|
38b8804b17 | ||
|
|
81ed1ce3ed | ||
|
|
92e7aa127c | ||
|
|
f618364632 | ||
|
|
20923d04b6 | ||
|
|
6d61297182 | ||
|
|
fb636e4152 | ||
|
|
527d174e9c | ||
|
|
f1bf32ee05 | ||
|
|
a5cc8fd16e | ||
|
|
1541afd470 | ||
|
|
d0deb26065 | ||
|
|
f04e4ffa8b | ||
|
|
17889df220 | ||
|
|
fe1121de65 | ||
|
|
2004a80055 | ||
|
|
f70b5ae6bd | ||
|
|
12b8324245 | ||
|
|
a9b648454e | ||
|
|
938a4b07bf | ||
|
|
7e43bd43a4 | ||
|
|
56926efd03 | ||
|
|
a6ee444f3b | ||
|
|
2dd73cf594 | ||
|
|
53038dea68 | ||
|
|
281934529e | ||
|
|
c905f136d2 | ||
|
|
36bf591afe | ||
|
|
550a9704ad | ||
|
|
55e681c209 | ||
|
|
e65ddc655e | ||
|
|
14b1cc7539 | ||
|
|
adc1f343b2 | ||
|
|
3dfaf69fb1 | ||
|
|
fd2a7a8e96 | ||
|
|
ebeb0cf865 | ||
|
|
46eb908ff4 | ||
|
|
616d6ba01c | ||
|
|
154f859efc | ||
|
|
591316aa22 | ||
|
|
89f2106d8b | ||
|
|
33c29fbff3 | ||
|
|
757d0493a0 | ||
|
|
50e637a9f2 | ||
|
|
4bb9393a83 | ||
|
|
ff3ea70826 | ||
|
|
010904d6e1 | ||
|
|
6b5f05bd2b | ||
|
|
cefcdf3072 | ||
|
|
9cacc6079e | ||
|
|
9d6c7b8605 | ||
|
|
c61b08d6de | ||
|
|
56d79c919e | ||
|
|
3318b5f1c6 | ||
|
|
71eaca9495 | ||
|
|
a3a7af123d | ||
|
|
5fd7e41492 | ||
|
|
0387e9f428 | ||
|
|
49f6b0a8c7 | ||
|
|
1b95d9472b | ||
|
|
4f5f8255a1 | ||
|
|
3addc72693 | ||
|
|
48286b9f77 | ||
|
|
e942699078 | ||
|
|
f352058bc6 | ||
|
|
252881b8d1 | ||
|
|
f88371e9af | ||
|
|
393cb52178 | ||
|
|
09d8fb5f95 | ||
|
|
9996055cac | ||
|
|
559b522507 | ||
|
|
3c54401bb2 | ||
|
|
06a489567a | ||
|
|
fabb517d0b | ||
|
|
cee16c1657 | ||
|
|
908173de97 | ||
|
|
8197db2c14 | ||
|
|
c8a834b91b | ||
|
|
8fc360a596 | ||
|
|
169e6dc578 | ||
|
|
04d3ac0415 | ||
|
|
a3e8a5e15e | ||
|
|
fffecb5bf6 | ||
|
|
f5645d6c32 | ||
|
|
27d7225330 | ||
|
|
241e4874ad | ||
|
|
272073f186 | ||
|
|
44e8891ca9 | ||
|
|
7141ae1e1f | ||
|
|
f4c99cabd5 | ||
|
|
3abdf9bb68 | ||
|
|
7b03aada3b | ||
|
|
707a7610f8 | ||
|
|
593638482d | ||
|
|
a3d750822c | ||
|
|
3987bbc1f9 | ||
|
|
d1e506135b | ||
|
|
ef9a85eee8 | ||
|
|
93107e7c59 | ||
|
|
5374bdabd4 | ||
|
|
7573d3b5da | ||
|
|
7dcb8bc705 | ||
|
|
29634c7f7a | ||
|
|
79185a2e34 | ||
|
|
209531ce0c | ||
|
|
4899e6301f | ||
|
|
9b24a88200 | ||
|
|
7155fbafd8 | ||
|
|
cb58e39f3c | ||
|
|
18b85bec1f | ||
|
|
26c58bf5dd | ||
|
|
c8f7225506 | ||
|
|
03ee9ccec4 | ||
|
|
64761d5c1f | ||
|
|
3b21aae44d | ||
|
|
5ac7880a2b | ||
|
|
9f73c2ee4a | ||
|
|
ae47af52b9 | ||
| 5facb52d21 | |||
|
|
9ed13f8bd5 | ||
|
|
bd34b59c15 | ||
|
|
6b15ea8b1f | ||
|
|
b1f82d91d2 | ||
|
|
adba3058b4 | ||
|
|
5bdd26c792 | ||
|
|
7eda0aefcc | ||
|
|
3e76ef5281 | ||
|
|
2171c3702a | ||
|
|
6976daa910 | ||
|
|
dc487e2f97 | ||
|
|
698a0fb15e | ||
|
|
a7b0bd96d4 | ||
|
|
7734ce7bae | ||
|
|
c8da2224f8 | ||
|
|
08f3f92167 | ||
|
|
1a849362a1 | ||
|
|
b948c9a46c | ||
|
|
df79eec5cc | ||
|
|
1d08522df8 | ||
|
|
2ce95f2542 | ||
|
|
49f71e32ff | ||
|
|
0610f0ee0f | ||
|
|
4aa3855936 | ||
|
|
0003b6d6ef | ||
|
|
147d1f2de5 | ||
|
|
968993c48e | ||
|
|
304359f67d | ||
|
|
bf46fe6d8b | ||
|
|
06fbb2fe81 | ||
|
|
3dd0ff94c6 | ||
|
|
a81959a591 | ||
|
|
d663ba87b0 | ||
|
|
0cc79cd0fd | ||
|
|
16101240f1 | ||
|
|
e28cd03953 | ||
|
|
b5580b0b24 | ||
|
|
4c3d253066 | ||
|
|
e7829312e8 | ||
|
|
2b0f467213 | ||
|
|
9a4e088de9 | ||
|
|
f9236cc575 | ||
|
|
e27af75e21 | ||
|
|
3983771e79 | ||
|
|
25d6ce4711 | ||
|
|
4820360e40 | ||
|
|
2fb5e4d17a | ||
|
|
29f81f48db | ||
|
|
070153a71d | ||
|
|
affee407ef | ||
|
|
4ff87b035e | ||
|
|
f568c0aeb7 | ||
|
|
9900d0b54b | ||
|
|
9ae6186e66 | ||
|
|
c21e19a15c | ||
|
|
7825c7749a | ||
|
|
d13422c65a | ||
|
|
23d0005514 | ||
|
|
dc6ea080c4 | ||
|
|
2bc3b3fb6c | ||
|
|
55cf1fb0a4 | ||
|
|
e455efa670 | ||
|
|
1615a4ffa5 | ||
|
|
bc62f3b0af | ||
|
|
420f50b6d5 | ||
|
|
d91a10ef8e | ||
|
|
44f495ca8b | ||
|
|
74bf49552b | ||
|
|
1de4f8a605 | ||
|
|
f8d888a5be | ||
|
|
29f0ec8a05 | ||
|
|
5db17880f9 | ||
|
|
ce02c1bf39 | ||
|
|
e1c09ddc7f | ||
|
|
93408c5825 | ||
|
|
2a2ce240e1 | ||
|
|
0bd7a70c96 | ||
|
|
a570dff4e9 | ||
|
|
fcff7fbdb1 | ||
|
|
5cf6947040 | ||
|
|
d053f6dc40 | ||
|
|
afebaf4c53 | ||
|
|
1bfe0ab022 | ||
|
|
6ebae19984 |
@@ -28,6 +28,10 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Compile Paraglide i18n
|
||||||
|
run: npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/lib/paraglide
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
@@ -65,6 +69,16 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-jetty</artifactId>
|
<artifactId>spring-boot-starter-jetty</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-postgresql</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator-test</artifactId>
|
<artifactId>spring-boot-starter-actuator-test</artifactId>
|
||||||
@@ -161,6 +175,50 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jacoco</groupId>
|
||||||
|
<artifactId>jacoco-maven-plugin</artifactId>
|
||||||
|
<version>0.8.12</version>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>**/dto/**</exclude>
|
||||||
|
<exclude>**/config/**</exclude>
|
||||||
|
<exclude>**/exception/ErrorCode*</exclude>
|
||||||
|
<exclude>**/model/**</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>prepare-agent</id>
|
||||||
|
<goals><goal>prepare-agent</goal></goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>report</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals><goal>report</goal></goals>
|
||||||
|
</execution>
|
||||||
|
<!-- Gate: baseline 89.4% overall / service 90.2% / controller 80.0% -->
|
||||||
|
<execution>
|
||||||
|
<id>check</id>
|
||||||
|
<phase>verify</phase>
|
||||||
|
<goals><goal>check</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<rules>
|
||||||
|
<rule>
|
||||||
|
<element>BUNDLE</element>
|
||||||
|
<limits>
|
||||||
|
<limit>
|
||||||
|
<counter>BRANCH</counter>
|
||||||
|
<value>COVEREDRATIO</value>
|
||||||
|
<minimum>0.88</minimum>
|
||||||
|
</limit>
|
||||||
|
</limits>
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public class AnnotationController {
|
|||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentAnnotation createAnnotation(
|
public DocumentAnnotation createAnnotation(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@RequestBody CreateAnnotationDTO dto,
|
@RequestBody CreateAnnotationDTO dto,
|
||||||
@@ -47,7 +47,7 @@ public class AnnotationController {
|
|||||||
|
|
||||||
@DeleteMapping("/{annotationId}")
|
@DeleteMapping("/{annotationId}")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public void deleteAnnotation(
|
public void deleteAnnotation(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID annotationId,
|
@PathVariable UUID annotationId,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
|||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +26,9 @@ public class AuthE2EController {
|
|||||||
|
|
||||||
private final PasswordResetTokenRepository tokenRepository;
|
private final PasswordResetTokenRepository tokenRepository;
|
||||||
|
|
||||||
|
// Hidden from the OpenAPI spec — this endpoint must never appear in the generated api.ts
|
||||||
|
// even when the e2e profile is active alongside the dev profile during spec generation.
|
||||||
|
@Operation(hidden = true)
|
||||||
@GetMapping("/reset-token-for-test")
|
@GetMapping("/reset-token-for-test")
|
||||||
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
|
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
|
||||||
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
|
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
|
||||||
|
|||||||
@@ -33,25 +33,25 @@ public class CommentController {
|
|||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/comments")
|
@PostMapping("/api/documents/{documentId}/comments")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment postDocumentComment(
|
public DocumentComment postDocumentComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.postComment(documentId, null, dto.getContent(), author);
|
return commentService.postComment(documentId, null, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment replyToDocumentComment(
|
public DocumentComment replyToDocumentComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID commentId,
|
@PathVariable UUID commentId,
|
||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Annotation comments ──────────────────────────────────────────────────
|
// ─── Annotation comments ──────────────────────────────────────────────────
|
||||||
@@ -63,32 +63,63 @@ public class CommentController {
|
|||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment postAnnotationComment(
|
public DocumentComment postAnnotationComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID annotationId,
|
@PathVariable UUID annotationId,
|
||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
|
return commentService.postComment(documentId, annotationId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
||||||
@ResponseStatus(HttpStatus.CREATED)
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment replyToAnnotationComment(
|
public DocumentComment replyToAnnotationComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID commentId,
|
@PathVariable UUID commentId,
|
||||||
@RequestBody CreateCommentDTO dto,
|
@RequestBody CreateCommentDTO dto,
|
||||||
Authentication authentication) {
|
Authentication authentication) {
|
||||||
AppUser author = resolveUser(authentication);
|
AppUser author = resolveUser(authentication);
|
||||||
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Block (transcription) comments ────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||||
|
public List<DocumentComment> getBlockComments(@PathVariable UUID blockId) {
|
||||||
|
return commentService.getCommentsForBlock(blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
|
public DocumentComment postBlockComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID blockId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.postBlockComment(documentId, blockId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/transcription-blocks/{blockId}/comments/{commentId}/replies")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
|
public DocumentComment replyToBlockComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), dto.getMentionedUserIds(), author);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
||||||
|
|
||||||
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
|
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||||
@RequirePermission(Permission.ANNOTATE_ALL)
|
@RequirePermission({Permission.ANNOTATE_ALL, Permission.WRITE_ALL})
|
||||||
public DocumentComment editComment(
|
public DocumentComment editComment(
|
||||||
@PathVariable UUID documentId,
|
@PathVariable UUID documentId,
|
||||||
@PathVariable UUID commentId,
|
@PathVariable UUID commentId,
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import java.util.UUID;
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
@@ -164,8 +167,9 @@ public class DocumentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/incomplete")
|
@GetMapping("/incomplete")
|
||||||
public List<Document> getIncomplete() {
|
public List<IncompleteDocumentDTO> getIncomplete(
|
||||||
return documentService.findIncompleteDocuments();
|
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
|
||||||
|
return documentService.findIncompleteDocuments(size);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/incomplete/next")
|
@GetMapping("/incomplete/next")
|
||||||
@@ -175,6 +179,12 @@ public class DocumentController {
|
|||||||
.orElse(ResponseEntity.noContent().build());
|
.orElse(ResponseEntity.noContent().build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/recent-activity")
|
||||||
|
public ResponseEntity<List<Document>> getRecentActivity(
|
||||||
|
@RequestParam(defaultValue = "5") int size) {
|
||||||
|
return ResponseEntity.ok(documentService.getRecentActivity(size));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/search")
|
@GetMapping("/search")
|
||||||
public ResponseEntity<List<Document>> search(
|
public ResponseEntity<List<Document>> search(
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
@@ -182,8 +192,9 @@ public class DocumentController {
|
|||||||
@RequestParam(required = false) LocalDate to,
|
@RequestParam(required = false) LocalDate to,
|
||||||
@RequestParam(required = false) UUID senderId,
|
@RequestParam(required = false) UUID senderId,
|
||||||
@RequestParam(required = false) UUID receiverId,
|
@RequestParam(required = false) UUID receiverId,
|
||||||
@RequestParam(required = false, name = "tag") List<String> tags) {
|
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags));
|
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) {
|
||||||
|
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- VERSIONS ---
|
// --- VERSIONS ---
|
||||||
@@ -201,7 +212,7 @@ public class DocumentController {
|
|||||||
@GetMapping("/conversation")
|
@GetMapping("/conversation")
|
||||||
public List<Document> getConversation(
|
public List<Document> getConversation(
|
||||||
@RequestParam UUID senderId,
|
@RequestParam UUID senderId,
|
||||||
@RequestParam UUID receiverId,
|
@RequestParam(required = false) UUID receiverId,
|
||||||
@RequestParam(required = false) LocalDate from,
|
@RequestParam(required = false) LocalDate from,
|
||||||
@RequestParam(required = false) LocalDate to,
|
@RequestParam(required = false) LocalDate to,
|
||||||
@RequestParam(defaultValue = "DESC") String dir) {
|
@RequestParam(defaultValue = "DESC") String dir) {
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
@@ -30,6 +33,26 @@ public class GlobalExceptionHandler {
|
|||||||
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ConstraintViolationException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException ex) {
|
||||||
|
String message = ex.getConstraintViolations().stream()
|
||||||
|
.map(v -> v.getPropertyPath() + ": " + v.getMessage())
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
|
||||||
|
String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'";
|
||||||
|
return ResponseEntity.badRequest().body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ResponseStatusException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleResponseStatus(ResponseStatusException ex) {
|
||||||
|
return ResponseEntity.status(ex.getStatusCode())
|
||||||
|
.body(new ErrorResponse(ErrorCode.VALIDATION_ERROR, ex.getReason()));
|
||||||
|
}
|
||||||
|
|
||||||
@ExceptionHandler(Exception.class)
|
@ExceptionHandler(Exception.class)
|
||||||
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
|
||||||
log.error("Unhandled exception", ex);
|
log.error("Unhandled exception", ex);
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.NotificationPreferenceDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.raddatz.familienarchiv.service.NotificationService;
|
||||||
|
import org.raddatz.familienarchiv.service.SseEmitterRegistry;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Validated
|
||||||
|
public class NotificationController {
|
||||||
|
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
private final UserService userService;
|
||||||
|
private final SseEmitterRegistry sseEmitterRegistry;
|
||||||
|
|
||||||
|
// These endpoints are intentionally open to any authenticated user —
|
||||||
|
// they return and mutate only the current user's own notifications, scoped
|
||||||
|
// by the resolved user identity. No additional permission check is required.
|
||||||
|
|
||||||
|
@GetMapping(value = "/api/notifications/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||||
|
public SseEmitter stream(Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
return sseEmitterRegistry.register(user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/notifications")
|
||||||
|
public Page<NotificationDTO> getNotifications(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "10") @Min(1) @Max(100) int size,
|
||||||
|
@Parameter(description = "Filter by notification type") @RequestParam(required = false) NotificationType type,
|
||||||
|
@Parameter(description = "Filter by read status") @RequestParam(required = false) Boolean read,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
|
||||||
|
return notificationService.getNotifications(user.getId(), type, read, pageable);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/notifications/unread-count")
|
||||||
|
public Map<String, Long> countUnread(Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
return Map.of("count", notificationService.countUnread(user.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/notifications/read-all")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void markAllRead(Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
notificationService.markAllRead(user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/api/notifications/{id}/read")
|
||||||
|
public NotificationDTO markOneRead(
|
||||||
|
@PathVariable UUID id,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
return notificationService.markRead(id, user.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/users/me/notification-preferences")
|
||||||
|
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
|
||||||
|
public NotificationPreferenceDTO getPreferences(Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
return new NotificationPreferenceDTO(user.isNotifyOnReply(), user.isNotifyOnMention());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/api/users/me/notification-preferences")
|
||||||
|
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
|
||||||
|
public NotificationPreferenceDTO updatePreferences(
|
||||||
|
@RequestBody NotificationPreferenceDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser user = resolveUser(authentication);
|
||||||
|
AppUser updated = notificationService.updatePreferences(
|
||||||
|
user.getId(), dto.notifyOnReply(), dto.notifyOnMention());
|
||||||
|
return new NotificationPreferenceDTO(updated.isNotifyOnReply(), updated.isNotifyOnMention());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private AppUser resolveUser(Authentication authentication) {
|
||||||
|
return userService.findByUsername(authentication.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,23 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
import org.raddatz.familienarchiv.service.PersonService;
|
import org.raddatz.familienarchiv.service.PersonService;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.validation.annotation.Validated;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -25,7 +32,7 @@ public class PersonController {
|
|||||||
private final DocumentService documentService;
|
private final DocumentService documentService;
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public ResponseEntity<List<Person>> getPersons(@RequestParam(required = false) String q) {
|
public ResponseEntity<List<PersonSummaryDTO>> getPersons(@RequestParam(required = false) String q) {
|
||||||
return ResponseEntity.ok(personService.findAll(q));
|
return ResponseEntity.ok(personService.findAll(q));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,17 +59,20 @@ public class PersonController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public ResponseEntity<Person> createPerson(@RequestBody Map<String, String> body) {
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
String firstName = body.get("firstName");
|
public ResponseEntity<Person> createPerson(@Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
String lastName = body.get("lastName");
|
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||||
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
|
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(personService.createPerson(firstName.trim(), lastName.trim(), body.get("alias")));
|
dto.setFirstName(dto.getFirstName().trim());
|
||||||
|
dto.setLastName(dto.getLastName().trim());
|
||||||
|
return ResponseEntity.ok(personService.createPerson(dto));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @RequestBody PersonUpdateDTO dto) {
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public ResponseEntity<Person> updatePerson(@PathVariable UUID id, @Valid @RequestBody PersonUpdateDTO dto) {
|
||||||
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
if (dto.getFirstName() == null || dto.getFirstName().isBlank()
|
||||||
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
|| dto.getLastName() == null || dto.getLastName().isBlank()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||||
@@ -74,6 +84,7 @@ public class PersonController {
|
|||||||
|
|
||||||
@PostMapping("/{id}/merge")
|
@PostMapping("/{id}/merge")
|
||||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
public void mergePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
|
public void mergePerson(@PathVariable UUID id, @RequestBody Map<String, String> body) {
|
||||||
String targetIdStr = body.get("targetPersonId");
|
String targetIdStr = body.get("targetPersonId");
|
||||||
if (targetIdStr == null || targetIdStr.isBlank()) {
|
if (targetIdStr == null || targetIdStr.isBlank()) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.StatsDTO;
|
||||||
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/stats")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StatsController {
|
||||||
|
|
||||||
|
private final PersonRepository personRepository;
|
||||||
|
private final DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<StatsDTO> getStats() {
|
||||||
|
return ResponseEntity.ok(new StatsDTO(personRepository.count(), documentRepository.count()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/documents/{documentId}/transcription-blocks")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class TranscriptionBlockController {
|
||||||
|
|
||||||
|
private final TranscriptionService transcriptionService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
|
public List<TranscriptionBlock> listBlocks(@PathVariable UUID documentId) {
|
||||||
|
return transcriptionService.listBlocks(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{blockId}")
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
|
public TranscriptionBlock getBlock(@PathVariable UUID documentId, @PathVariable UUID blockId) {
|
||||||
|
return transcriptionService.getBlock(documentId, blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public TranscriptionBlock createBlock(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@RequestBody CreateTranscriptionBlockDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
UUID userId = requireUserId(authentication);
|
||||||
|
return transcriptionService.createBlock(documentId, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{blockId}")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public TranscriptionBlock updateBlock(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID blockId,
|
||||||
|
@RequestBody UpdateTranscriptionBlockDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
UUID userId = requireUserId(authentication);
|
||||||
|
return transcriptionService.updateBlock(documentId, blockId, dto, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{blockId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public void deleteBlock(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID blockId) {
|
||||||
|
transcriptionService.deleteBlock(documentId, blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/reorder")
|
||||||
|
@RequirePermission(Permission.WRITE_ALL)
|
||||||
|
public List<TranscriptionBlock> reorderBlocks(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@RequestBody ReorderTranscriptionBlocksDTO dto) {
|
||||||
|
transcriptionService.reorderBlocks(documentId, dto);
|
||||||
|
return transcriptionService.listBlocks(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{blockId}/history")
|
||||||
|
@RequirePermission(Permission.READ_ALL)
|
||||||
|
public List<TranscriptionBlockVersion> getBlockHistory(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID blockId) {
|
||||||
|
return transcriptionService.getBlockHistory(documentId, blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UUID requireUserId(Authentication authentication) {
|
||||||
|
if (authentication == null || !authentication.isAuthenticated()) {
|
||||||
|
throw DomainException.unauthorized("Authentication required");
|
||||||
|
}
|
||||||
|
AppUser user = userService.findByUsername(authentication.getName());
|
||||||
|
if (user == null) {
|
||||||
|
throw DomainException.unauthorized("User not found");
|
||||||
|
}
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ public class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("users/{id}")
|
@GetMapping("users/{id}")
|
||||||
|
@RequirePermission(Permission.ADMIN_USER)
|
||||||
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {
|
public ResponseEntity<AppUser> getUser(@PathVariable UUID id) {
|
||||||
AppUser user = userService.getById(id);
|
AppUser user = userService.getById(id);
|
||||||
user.setPassword(null);
|
user.setPassword(null);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.raddatz.familienarchiv.service.UserSearchService;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@RequirePermission({Permission.READ_ALL, Permission.WRITE_ALL, Permission.ANNOTATE_ALL})
|
||||||
|
public class UserSearchController {
|
||||||
|
|
||||||
|
private final UserSearchService userSearchService;
|
||||||
|
|
||||||
|
@GetMapping("/api/users/search")
|
||||||
|
public List<MentionDTO> search(@RequestParam(defaultValue = "") String q) {
|
||||||
|
return userSearchService.search(q).stream()
|
||||||
|
.map(this::toMentionDTO)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private MentionDTO toMentionDTO(AppUser user) {
|
||||||
|
return new MentionDTO(user.getId(), user.getFirstName(), user.getLastName());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ package org.raddatz.familienarchiv.dto;
|
|||||||
|
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class CreateCommentDTO {
|
public class CreateCommentDTO {
|
||||||
private String content;
|
private String content;
|
||||||
|
private List<UUID> mentionedUserIds = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CreateTranscriptionBlockDTO {
|
||||||
|
@Min(0)
|
||||||
|
private int pageNumber;
|
||||||
|
@Min(0)
|
||||||
|
private double x;
|
||||||
|
@Min(0)
|
||||||
|
private double y;
|
||||||
|
@Positive
|
||||||
|
private double width;
|
||||||
|
@Positive
|
||||||
|
private double height;
|
||||||
|
private String text;
|
||||||
|
private String label;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record IncompleteDocumentDTO(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record MentionDTO(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String firstName,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String lastName
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public record NotificationDTO(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) NotificationType type,
|
||||||
|
UUID documentId,
|
||||||
|
UUID referenceId,
|
||||||
|
UUID annotationId,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) boolean read,
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime createdAt,
|
||||||
|
String actorName,
|
||||||
|
String documentTitle
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
public record NotificationPreferenceDTO(boolean notifyOnReply, boolean notifyOnMention) {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projection returned by the /api/persons list endpoint.
|
||||||
|
* Includes document count to avoid N+1 queries in the UI.
|
||||||
|
* Uses interface projection for compatibility with native queries.
|
||||||
|
*/
|
||||||
|
public interface PersonSummaryDTO {
|
||||||
|
UUID getId();
|
||||||
|
String getFirstName();
|
||||||
|
String getLastName();
|
||||||
|
String getAlias();
|
||||||
|
Integer getBirthYear();
|
||||||
|
Integer getDeathYear();
|
||||||
|
String getNotes();
|
||||||
|
long getDocumentCount();
|
||||||
|
}
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
package org.raddatz.familienarchiv.dto;
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public class PersonUpdateDTO {
|
public class PersonUpdateDTO {
|
||||||
|
@Size(max = 100)
|
||||||
private String firstName;
|
private String firstName;
|
||||||
|
@Size(max = 100)
|
||||||
private String lastName;
|
private String lastName;
|
||||||
|
@Size(max = 200)
|
||||||
private String alias;
|
private String alias;
|
||||||
|
@Size(max = 5000)
|
||||||
private String notes;
|
private String notes;
|
||||||
private Integer birthYear;
|
private Integer birthYear;
|
||||||
private Integer deathYear;
|
private Integer deathYear;
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ReorderTranscriptionBlocksDTO {
|
||||||
|
private List<UUID> blockIds;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate counts for the dashboard/persons stats bar.
|
||||||
|
*/
|
||||||
|
public record StatsDTO(long totalPersons, long totalDocuments) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class UpdateTranscriptionBlockDTO {
|
||||||
|
private String text;
|
||||||
|
private String label;
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ package org.raddatz.familienarchiv.exception;
|
|||||||
*/
|
*/
|
||||||
public enum ErrorCode {
|
public enum ErrorCode {
|
||||||
|
|
||||||
|
// --- Persons ---
|
||||||
|
/** A person with the given ID does not exist. 404 */
|
||||||
|
PERSON_NOT_FOUND,
|
||||||
|
|
||||||
// --- Documents ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
DOCUMENT_NOT_FOUND,
|
DOCUMENT_NOT_FOUND,
|
||||||
@@ -46,10 +50,20 @@ public enum ErrorCode {
|
|||||||
/** The new annotation overlaps an existing one on the same page. 409 */
|
/** The new annotation overlaps an existing one on the same page. 409 */
|
||||||
ANNOTATION_OVERLAP,
|
ANNOTATION_OVERLAP,
|
||||||
|
|
||||||
|
// --- Transcription Blocks ---
|
||||||
|
/** The transcription block with the given ID does not exist. 404 */
|
||||||
|
TRANSCRIPTION_BLOCK_NOT_FOUND,
|
||||||
|
/** Optimistic locking conflict — block was modified by another user. 409 */
|
||||||
|
TRANSCRIPTION_BLOCK_CONFLICT,
|
||||||
|
|
||||||
// --- Comments ---
|
// --- Comments ---
|
||||||
/** The comment with the given ID does not exist. 404 */
|
/** The comment with the given ID does not exist. 404 */
|
||||||
COMMENT_NOT_FOUND,
|
COMMENT_NOT_FOUND,
|
||||||
|
|
||||||
|
// --- Notifications ---
|
||||||
|
/** The notification with the given ID does not exist. 404 */
|
||||||
|
NOTIFICATION_NOT_FOUND,
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ public class AppUser {
|
|||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
|
private boolean enabled = true; // Um User zu sperren ohne sie zu löschen
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private boolean notifyOnReply = false;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private boolean notifyOnMention = false;
|
||||||
|
|
||||||
// Ein User kann in mehreren Gruppen sein
|
// Ein User kann in mehreren Gruppen sein
|
||||||
@ManyToMany(fetch = FetchType.EAGER)
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
|
@JoinTable(name = "users_groups", joinColumns = @JoinColumn(name = "user_id"), inverseJoinColumns = @JoinColumn(name = "group_id"))
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package org.raddatz.familienarchiv.model;
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.CreationTimestamp;
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
import org.hibernate.annotations.UpdateTimestamp;
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -31,6 +33,9 @@ public class DocumentComment {
|
|||||||
@Column(name = "annotation_id")
|
@Column(name = "annotation_id")
|
||||||
private UUID annotationId;
|
private UUID annotationId;
|
||||||
|
|
||||||
|
@Column(name = "block_id")
|
||||||
|
private UUID blockId;
|
||||||
|
|
||||||
@Column(name = "parent_id")
|
@Column(name = "parent_id")
|
||||||
private UUID parentId;
|
private UUID parentId;
|
||||||
|
|
||||||
@@ -60,4 +65,21 @@ public class DocumentComment {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private List<DocumentComment> replies = new ArrayList<>();
|
private List<DocumentComment> replies = new ArrayList<>();
|
||||||
|
|
||||||
|
// JPA join table for structured mention references — not serialized directly
|
||||||
|
@ManyToMany(fetch = FetchType.EAGER)
|
||||||
|
@JoinTable(
|
||||||
|
name = "comment_mentions",
|
||||||
|
joinColumns = @JoinColumn(name = "comment_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "user_id")
|
||||||
|
)
|
||||||
|
@JsonIgnore
|
||||||
|
@Builder.Default
|
||||||
|
private List<AppUser> mentions = new ArrayList<>();
|
||||||
|
|
||||||
|
// Populated by CommentService before serialization — not persisted.
|
||||||
|
@Transient
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private List<MentionDTO> mentionDTOs = new ArrayList<>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "notifications")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class Notification {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "recipient_id", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private AppUser recipient;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private NotificationType type;
|
||||||
|
|
||||||
|
@Column(name = "document_id")
|
||||||
|
private UUID documentId;
|
||||||
|
|
||||||
|
@Column(name = "reference_id")
|
||||||
|
private UUID referenceId;
|
||||||
|
|
||||||
|
@Column(name = "annotation_id")
|
||||||
|
private UUID annotationId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private boolean read = false;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "actor_name")
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String actorName;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
public enum NotificationType {
|
||||||
|
REPLY,
|
||||||
|
MENTION
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "transcription_blocks")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class TranscriptionBlock {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "annotation_id", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID annotationId;
|
||||||
|
|
||||||
|
@Column(name = "document_id", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID documentId;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String text;
|
||||||
|
|
||||||
|
@Column(length = 200)
|
||||||
|
private String label;
|
||||||
|
|
||||||
|
@Column(name = "sort_order", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private int sortOrder;
|
||||||
|
|
||||||
|
@Version
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private int version;
|
||||||
|
|
||||||
|
@Column(name = "created_by")
|
||||||
|
private UUID createdBy;
|
||||||
|
|
||||||
|
@Column(name = "updated_by")
|
||||||
|
private UUID updatedBy;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
@CreationTimestamp
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.*;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "transcription_block_versions")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class TranscriptionBlockVersion {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "block_id", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID blockId;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String text;
|
||||||
|
|
||||||
|
@Column(name = "changed_by")
|
||||||
|
private UUID changedBy;
|
||||||
|
|
||||||
|
@Column(name = "changed_at", nullable = false, updatable = false)
|
||||||
|
@CreationTimestamp
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private LocalDateTime changedAt;
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
package org.raddatz.familienarchiv.repository;
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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 org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -12,4 +15,9 @@ import java.util.UUID;
|
|||||||
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
public interface AppUserRepository extends JpaRepository<AppUser, UUID> {
|
||||||
Optional<AppUser> findByUsername(String username);
|
Optional<AppUser> findByUsername(String username);
|
||||||
Optional<AppUser> findByEmail(String email);
|
Optional<AppUser> findByEmail(String email);
|
||||||
|
|
||||||
|
@Query("SELECT u FROM AppUser u WHERE " +
|
||||||
|
"LOWER(COALESCE(u.firstName, '') || ' ' || COALESCE(u.lastName, '')) LIKE LOWER(CONCAT('%', :q, '%')) " +
|
||||||
|
"OR LOWER(u.username) LIKE LOWER(CONCAT('%', :q, '%'))")
|
||||||
|
List<AppUser> searchByNameOrUsername(@Param("q") String q, Pageable pageable);
|
||||||
}
|
}
|
||||||
@@ -13,4 +13,6 @@ public interface CommentRepository extends JpaRepository<DocumentComment, UUID>
|
|||||||
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
|
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
|
||||||
|
|
||||||
List<DocumentComment> findByParentId(UUID parentId);
|
List<DocumentComment> findByParentId(UUID parentId);
|
||||||
|
|
||||||
|
List<DocumentComment> findByBlockIdAndParentIdIsNull(UUID blockId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package org.raddatz.familienarchiv.repository;
|
|||||||
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
@@ -10,7 +12,9 @@ import org.springframework.data.repository.query.Param;
|
|||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@@ -42,20 +46,23 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
|
||||||
|
@Query("SELECT d.id, d.title FROM Document d WHERE d.id IN :ids")
|
||||||
|
List<Object[]> findIdAndTitleByIdIn(@Param("ids") Collection<UUID> ids);
|
||||||
|
|
||||||
long countByMetadataCompleteFalse();
|
long countByMetadataCompleteFalse();
|
||||||
|
|
||||||
List<Document> findByMetadataCompleteFalse(Sort sort);
|
List<Document> findByMetadataCompleteFalse(Sort sort);
|
||||||
|
|
||||||
|
Page<Document> findByMetadataCompleteFalse(Pageable pageable);
|
||||||
|
|
||||||
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
Optional<Document> findFirstByMetadataCompleteFalseAndIdNot(UUID id, Sort sort);
|
||||||
|
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"JOIN d.receivers r " +
|
"JOIN d.receivers r " +
|
||||||
"WHERE " +
|
"WHERE " +
|
||||||
// Logik: (Sender A an Empfänger B) ODER (Sender B an Empfänger A)
|
|
||||||
"((d.sender.id = :person1 AND r.id = :person2) " +
|
"((d.sender.id = :person1 AND r.id = :person2) " +
|
||||||
" OR " +
|
" OR " +
|
||||||
" (d.sender.id = :person2 AND r.id = :person1)) " +
|
" (d.sender.id = :person2 AND r.id = :person1)) " +
|
||||||
// UND das Datum stimmt
|
|
||||||
"AND d.documentDate BETWEEN :from AND :to")
|
"AND d.documentDate BETWEEN :from AND :to")
|
||||||
List<Document> findConversation(
|
List<Document> findConversation(
|
||||||
@Param("person1") UUID person1,
|
@Param("person1") UUID person1,
|
||||||
@@ -64,4 +71,14 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
@Param("to") LocalDate to,
|
@Param("to") LocalDate to,
|
||||||
Sort sort);
|
Sort sort);
|
||||||
|
|
||||||
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
|
"LEFT JOIN d.receivers r " +
|
||||||
|
"WHERE (d.sender.id = :personId OR r.id = :personId) " +
|
||||||
|
"AND d.documentDate BETWEEN :from AND :to")
|
||||||
|
List<Document> findSinglePersonCorrespondence(
|
||||||
|
@Param("personId") UUID personId,
|
||||||
|
@Param("from") LocalDate from,
|
||||||
|
@Param("to") LocalDate to,
|
||||||
|
Sort sort);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import java.util.List;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
@@ -55,6 +56,11 @@ public class DocumentSpecifications {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filtert nach Status
|
||||||
|
public static Specification<Document> hasStatus(DocumentStatus status) {
|
||||||
|
return (root, query, cb) -> status == null ? null : cb.equal(root.get("status"), status);
|
||||||
|
}
|
||||||
|
|
||||||
// Filtert nach Schlagworten (UND-Verknüpfung)
|
// Filtert nach Schlagworten (UND-Verknüpfung)
|
||||||
public static Specification<Document> hasTags(List<String> tags) {
|
public static Specification<Document> hasTags(List<String> tags) {
|
||||||
return (root, query, cb) -> {
|
return (root, query, cb) -> {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.Notification;
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
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.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface NotificationRepository extends JpaRepository<Notification, UUID> {
|
||||||
|
|
||||||
|
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
|
||||||
|
|
||||||
|
Page<Notification> findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||||
|
UUID recipientId, NotificationType type, Pageable pageable);
|
||||||
|
|
||||||
|
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
UUID recipientId, NotificationType type, Pageable pageable);
|
||||||
|
|
||||||
|
Page<Notification> findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
UUID recipientId, Pageable pageable);
|
||||||
|
|
||||||
|
long countByRecipientIdAndReadFalse(UUID recipientId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE Notification n SET n.read = true WHERE n.recipient.id = :userId")
|
||||||
|
void markAllReadByRecipientId(@Param("userId") UUID userId);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
@@ -31,6 +32,33 @@ public interface PersonRepository extends JpaRepository<Person, UUID> {
|
|||||||
// Exact first+last name match, used for filename-based sender lookup
|
// Exact first+last name match, used for filename-based sender lookup
|
||||||
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
Optional<Person> findByFirstNameIgnoreCaseAndLastNameIgnoreCase(String firstName, String lastName);
|
||||||
|
|
||||||
|
// --- PersonSummaryDTO with document count ---
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
|
FROM persons p
|
||||||
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
|
""",
|
||||||
|
nativeQuery = true)
|
||||||
|
List<PersonSummaryDTO> findAllWithDocumentCount();
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
SELECT p.id, p.first_name AS firstName, p.last_name AS lastName,
|
||||||
|
p.alias, p.birth_year AS birthYear, p.death_year AS deathYear, p.notes,
|
||||||
|
(SELECT COUNT(*) FROM documents d WHERE d.sender_id = p.id)
|
||||||
|
+ (SELECT COUNT(*) FROM document_receivers dr WHERE dr.person_id = p.id) AS documentCount
|
||||||
|
FROM persons p
|
||||||
|
WHERE LOWER(CONCAT(p.first_name,' ',p.last_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
|
OR LOWER(CONCAT(p.last_name,' ',p.first_name)) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
|
OR LOWER(p.alias) LIKE LOWER(CONCAT('%',:query,'%'))
|
||||||
|
ORDER BY p.last_name ASC, p.first_name ASC
|
||||||
|
""",
|
||||||
|
nativeQuery = true)
|
||||||
|
List<PersonSummaryDTO> searchWithDocumentCount(@Param("query") String query);
|
||||||
|
|
||||||
// --- Correspondent queries ---
|
// --- Correspondent queries ---
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface TranscriptionBlockRepository extends JpaRepository<TranscriptionBlock, UUID> {
|
||||||
|
|
||||||
|
List<TranscriptionBlock> findByDocumentIdOrderBySortOrderAsc(UUID documentId);
|
||||||
|
|
||||||
|
Optional<TranscriptionBlock> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||||
|
|
||||||
|
int countByDocumentId(UUID documentId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface TranscriptionBlockVersionRepository extends JpaRepository<TranscriptionBlockVersion, UUID> {
|
||||||
|
|
||||||
|
List<TranscriptionBlockVersion> findByBlockIdOrderByChangedAtDesc(UUID blockId);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ public class PermissionAspect {
|
|||||||
RequirePermission permission = getAnnotation(joinPoint);
|
RequirePermission permission = getAnnotation(joinPoint);
|
||||||
|
|
||||||
if (permission != null) {
|
if (permission != null) {
|
||||||
validateUserAccess(permission.value());
|
validateUserAccess(permission.value()); // value() is now Permission[]
|
||||||
}
|
}
|
||||||
|
|
||||||
return joinPoint.proceed();
|
return joinPoint.proceed();
|
||||||
@@ -43,18 +43,23 @@ public class PermissionAspect {
|
|||||||
return joinPoint.getTarget().getClass().getAnnotation(RequirePermission.class);
|
return joinPoint.getTarget().getClass().getAnnotation(RequirePermission.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateUserAccess(Permission requiredPerm) {
|
private void validateUserAccess(Permission[] requiredPerms) {
|
||||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
|
||||||
if (auth == null || !auth.isAuthenticated()) {
|
if (auth == null || !auth.isAuthenticated()) {
|
||||||
throw DomainException.unauthorized("Not authenticated");
|
throw DomainException.unauthorized("Not authenticated");
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean hasPermission = auth.getAuthorities().stream()
|
boolean hasAny = auth.getAuthorities().stream()
|
||||||
.anyMatch(a -> a.getAuthority().equals(requiredPerm.name()));
|
.anyMatch(a -> {
|
||||||
|
for (Permission p : requiredPerms) {
|
||||||
|
if (a.getAuthority().equals(p.name())) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
if (!hasPermission) {
|
if (!hasAny) {
|
||||||
throw DomainException.forbidden("Missing required permission: " + requiredPerm.name());
|
throw DomainException.forbidden("Missing required permission");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ import java.lang.annotation.Target;
|
|||||||
@Target({ElementType.METHOD, ElementType.TYPE})
|
@Target({ElementType.METHOD, ElementType.TYPE})
|
||||||
@Retention(RetentionPolicy.RUNTIME)
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
public @interface RequirePermission {
|
public @interface RequirePermission {
|
||||||
Permission value(); // e.g. "ADMIN" or "WRITE_ALL"
|
Permission[] value(); // one or more — user needs any of the listed permissions
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package org.raddatz.familienarchiv.service;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.dto.MentionDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
@@ -9,7 +10,9 @@ import org.raddatz.familienarchiv.repository.CommentRepository;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -17,20 +20,45 @@ import java.util.UUID;
|
|||||||
public class CommentService {
|
public class CommentService {
|
||||||
|
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
|
private final UserService userService;
|
||||||
|
private final NotificationService notificationService;
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||||
List<DocumentComment> roots =
|
List<DocumentComment> roots =
|
||||||
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
||||||
return withReplies(roots);
|
return withRepliesAndMentions(roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
||||||
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
||||||
return withReplies(roots);
|
return withRepliesAndMentions(roots);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DocumentComment> getCommentsForBlock(UUID blockId) {
|
||||||
|
List<DocumentComment> roots = commentRepository.findByBlockIdAndParentIdIsNull(blockId);
|
||||||
|
return withRepliesAndMentions(roots);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
|
public DocumentComment postBlockComment(UUID documentId, UUID blockId, String content,
|
||||||
|
List<UUID> mentionedUserIds, AppUser author) {
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.documentId(documentId)
|
||||||
|
.blockId(blockId)
|
||||||
|
.content(content)
|
||||||
|
.authorId(author.getId())
|
||||||
|
.authorName(resolveAuthorName(author))
|
||||||
|
.build();
|
||||||
|
saveMentions(comment, mentionedUserIds);
|
||||||
|
DocumentComment saved = commentRepository.save(comment);
|
||||||
|
withMentionDTOs(saved);
|
||||||
|
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DocumentComment postComment(UUID documentId, UUID annotationId, String content,
|
||||||
|
List<UUID> mentionedUserIds, AppUser author) {
|
||||||
DocumentComment comment = DocumentComment.builder()
|
DocumentComment comment = DocumentComment.builder()
|
||||||
.documentId(documentId)
|
.documentId(documentId)
|
||||||
.annotationId(annotationId)
|
.annotationId(annotationId)
|
||||||
@@ -38,11 +66,16 @@ public class CommentService {
|
|||||||
.authorId(author.getId())
|
.authorId(author.getId())
|
||||||
.authorName(resolveAuthorName(author))
|
.authorName(resolveAuthorName(author))
|
||||||
.build();
|
.build();
|
||||||
return commentRepository.save(comment);
|
saveMentions(comment, mentionedUserIds);
|
||||||
|
DocumentComment saved = commentRepository.save(comment);
|
||||||
|
withMentionDTOs(saved);
|
||||||
|
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
|
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content,
|
||||||
|
List<UUID> mentionedUserIds, AppUser author) {
|
||||||
DocumentComment target = commentRepository.findById(commentId)
|
DocumentComment target = commentRepository.findById(commentId)
|
||||||
.orElseThrow(() -> DomainException.notFound(
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
||||||
@@ -60,7 +93,15 @@ public class CommentService {
|
|||||||
.authorId(author.getId())
|
.authorId(author.getId())
|
||||||
.authorName(resolveAuthorName(author))
|
.authorName(resolveAuthorName(author))
|
||||||
.build();
|
.build();
|
||||||
return commentRepository.save(reply);
|
saveMentions(reply, mentionedUserIds);
|
||||||
|
DocumentComment saved = commentRepository.save(reply);
|
||||||
|
withMentionDTOs(saved);
|
||||||
|
|
||||||
|
Set<UUID> participantIds = collectParticipantIds(root);
|
||||||
|
participantIds.remove(author.getId());
|
||||||
|
notificationService.notifyReply(saved, participantIds);
|
||||||
|
notificationService.notifyMentions(mentionedUserIds, saved);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -84,13 +125,45 @@ public class CommentService {
|
|||||||
commentRepository.delete(comment);
|
commentRepository.delete(comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<DocumentComment> findReplies(UUID parentId) {
|
||||||
|
return commentRepository.findByParentId(parentId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── private helpers ──────────────────────────────────────────────────────
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
|
private List<DocumentComment> withRepliesAndMentions(List<DocumentComment> roots) {
|
||||||
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
|
roots.forEach(root -> {
|
||||||
|
List<DocumentComment> replies = commentRepository.findByParentId(root.getId());
|
||||||
|
replies.forEach(this::withMentionDTOs);
|
||||||
|
root.setReplies(replies);
|
||||||
|
withMentionDTOs(root);
|
||||||
|
});
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void saveMentions(DocumentComment comment, List<UUID> mentionedUserIds) {
|
||||||
|
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
|
||||||
|
List<AppUser> users = userService.findAllById(mentionedUserIds);
|
||||||
|
comment.setMentions(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void withMentionDTOs(DocumentComment comment) {
|
||||||
|
List<MentionDTO> dtos = comment.getMentions().stream()
|
||||||
|
.map(u -> new MentionDTO(u.getId(), u.getFirstName(), u.getLastName()))
|
||||||
|
.toList();
|
||||||
|
comment.setMentionDTOs(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Set<UUID> collectParticipantIds(DocumentComment root) {
|
||||||
|
Set<UUID> ids = new LinkedHashSet<>();
|
||||||
|
if (root.getAuthorId() != null) ids.add(root.getAuthorId());
|
||||||
|
commentRepository.findByParentId(root.getId())
|
||||||
|
.forEach(reply -> {
|
||||||
|
if (reply.getAuthorId() != null) ids.add(reply.getAuthorId());
|
||||||
|
});
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
private DocumentComment findComment(UUID documentId, UUID commentId) {
|
private DocumentComment findComment(UUID documentId, UUID commentId) {
|
||||||
return commentRepository.findById(commentId)
|
return commentRepository.findById(commentId)
|
||||||
.filter(c -> documentId.equals(c.getDocumentId()))
|
.filter(c -> documentId.equals(c.getDocumentId()))
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.data.jpa.domain.Specification;
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
@@ -23,8 +25,11 @@ import java.security.NoSuchAlgorithmException;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -44,6 +49,15 @@ public class DocumentService {
|
|||||||
|
|
||||||
public record StoreResult(Document document, boolean isNew) {}
|
public record StoreResult(Document document, boolean isNew) {}
|
||||||
|
|
||||||
|
public Map<UUID, String> findTitlesByIds(Collection<UUID> ids) {
|
||||||
|
if (ids.isEmpty()) return Map.of();
|
||||||
|
Map<UUID, String> titles = new HashMap<>();
|
||||||
|
for (Object[] row : documentRepository.findIdAndTitleByIdIn(ids)) {
|
||||||
|
titles.put((UUID) row[0], (String) row[1]);
|
||||||
|
}
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Datei hoch.
|
* Lädt eine Datei hoch.
|
||||||
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
* - Prüft, ob ein Eintrag (aus Excel) schon existiert.
|
||||||
@@ -258,13 +272,21 @@ public class DocumentService {
|
|||||||
return documentRepository.save(doc);
|
return documentRepository.save(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
|
||||||
|
public List<Document> getRecentActivity(int size) {
|
||||||
|
return documentRepository.findAll(
|
||||||
|
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
|
||||||
|
).getContent();
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags) {
|
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
|
||||||
Specification<Document> spec = Specification.where(hasText(text))
|
Specification<Document> spec = Specification.where(hasText(text))
|
||||||
.and(isBetween(from, to))
|
.and(isBetween(from, to))
|
||||||
.and(hasSender(sender))
|
.and(hasSender(sender))
|
||||||
.and(hasReceiver(receiver))
|
.and(hasReceiver(receiver))
|
||||||
.and(hasTags(tags));
|
.and(hasTags(tags))
|
||||||
|
.and(hasStatus(status));
|
||||||
|
|
||||||
// Neueste zuerst (nach Erstellungsdatum)
|
// Neueste zuerst (nach Erstellungsdatum)
|
||||||
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
|
return documentRepository.findAll(spec, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
@@ -306,6 +328,9 @@ public class DocumentService {
|
|||||||
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
|
||||||
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
|
||||||
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
LocalDate dateTo = (to != null) ? to : LocalDate.now();
|
||||||
|
if (receiverId == null) {
|
||||||
|
return documentRepository.findSinglePersonCorrespondence(senderId, dateFrom, dateTo, sort);
|
||||||
|
}
|
||||||
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,8 +338,12 @@ public class DocumentService {
|
|||||||
return documentRepository.countByMetadataCompleteFalse();
|
return documentRepository.countByMetadataCompleteFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Document> findIncompleteDocuments() {
|
public List<IncompleteDocumentDTO> findIncompleteDocuments(int size) {
|
||||||
return documentRepository.findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
|
return documentRepository.findByMetadataCompleteFalse(pageable)
|
||||||
|
.stream()
|
||||||
|
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<Document> findNextIncompleteDocument(UUID currentId) {
|
public Optional<Document> findNextIncompleteDocument(UUID currentId) {
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.model.Notification;
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.mail.MailException;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Propagation;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class NotificationService {
|
||||||
|
|
||||||
|
private final NotificationRepository notificationRepository;
|
||||||
|
private final UserService userService;
|
||||||
|
private final DocumentService documentService;
|
||||||
|
private final Optional<JavaMailSender> mailSender;
|
||||||
|
private final SseEmitterRegistry sseEmitterRegistry;
|
||||||
|
|
||||||
|
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
||||||
|
private String mailFrom;
|
||||||
|
|
||||||
|
@Value("${app.base-url:http://localhost:3000}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates REPLY notifications for all participants in the thread, excluding the replier.
|
||||||
|
* Runs in a separate transaction so a notification failure cannot roll back the parent comment.
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void notifyReply(DocumentComment reply, Set<UUID> participantIds) {
|
||||||
|
if (participantIds.isEmpty()) return;
|
||||||
|
|
||||||
|
List<AppUser> recipients = userService.findAllById(participantIds);
|
||||||
|
for (AppUser recipient : recipients) {
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.recipient(recipient)
|
||||||
|
.type(NotificationType.REPLY)
|
||||||
|
.documentId(reply.getDocumentId())
|
||||||
|
.referenceId(reply.getId())
|
||||||
|
.annotationId(reply.getAnnotationId())
|
||||||
|
.actorName(reply.getAuthorName())
|
||||||
|
.build();
|
||||||
|
saveAndPush(notification);
|
||||||
|
|
||||||
|
if (recipient.isNotifyOnReply()) {
|
||||||
|
sendNotificationEmail(recipient, reply, NotificationType.REPLY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates MENTION notifications for each mentioned user.
|
||||||
|
* Runs in a separate transaction so a notification failure cannot roll back the parent comment.
|
||||||
|
*/
|
||||||
|
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||||
|
public void notifyMentions(List<UUID> mentionedUserIds, DocumentComment comment) {
|
||||||
|
if (mentionedUserIds == null || mentionedUserIds.isEmpty()) return;
|
||||||
|
|
||||||
|
List<AppUser> recipients = userService.findAllById(mentionedUserIds);
|
||||||
|
for (AppUser recipient : recipients) {
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.recipient(recipient)
|
||||||
|
.type(NotificationType.MENTION)
|
||||||
|
.documentId(comment.getDocumentId())
|
||||||
|
.referenceId(comment.getId())
|
||||||
|
.annotationId(comment.getAnnotationId())
|
||||||
|
.actorName(comment.getAuthorName())
|
||||||
|
.build();
|
||||||
|
saveAndPush(notification);
|
||||||
|
|
||||||
|
if (recipient.isNotifyOnMention()) {
|
||||||
|
sendNotificationEmail(recipient, comment, NotificationType.MENTION);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page<NotificationDTO> getNotifications(UUID userId, NotificationType type, Boolean read, Pageable pageable) {
|
||||||
|
Page<Notification> page;
|
||||||
|
if (type != null && Boolean.FALSE.equals(read)) {
|
||||||
|
page = notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable);
|
||||||
|
} else if (type != null) {
|
||||||
|
page = notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable);
|
||||||
|
} else if (Boolean.FALSE.equals(read)) {
|
||||||
|
page = notificationRepository.findByRecipientIdAndReadFalseOrderByCreatedAtDesc(userId, pageable);
|
||||||
|
} else {
|
||||||
|
page = notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable);
|
||||||
|
}
|
||||||
|
return mapWithDocumentTitles(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Page<NotificationDTO> mapWithDocumentTitles(Page<Notification> page) {
|
||||||
|
Set<UUID> documentIds = page.getContent().stream()
|
||||||
|
.map(Notification::getDocumentId)
|
||||||
|
.filter(id -> id != null)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
Map<UUID, String> titles = documentService.findTitlesByIds(documentIds);
|
||||||
|
return page.map(n -> toDTO(n, titles));
|
||||||
|
}
|
||||||
|
|
||||||
|
public long countUnread(UUID userId) {
|
||||||
|
return notificationRepository.countByRecipientIdAndReadFalse(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void markAllRead(UUID userId) {
|
||||||
|
notificationRepository.markAllReadByRecipientId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public NotificationDTO markRead(UUID notificationId, UUID userId) {
|
||||||
|
Notification notification = notificationRepository.findById(notificationId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notificationId));
|
||||||
|
if (!notification.getRecipient().getId().equals(userId)) {
|
||||||
|
throw DomainException.forbidden("Notification belongs to a different user");
|
||||||
|
}
|
||||||
|
notification.setRead(true);
|
||||||
|
return toDTO(notificationRepository.save(notification), Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AppUser updatePreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
|
||||||
|
return userService.updateNotificationPreferences(userId, notifyOnReply, notifyOnMention);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void saveAndPush(Notification notification) {
|
||||||
|
Notification saved = notificationRepository.save(notification);
|
||||||
|
sseEmitterRegistry.send(saved.getRecipient().getId(), toDTO(saved, Map.of()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private NotificationDTO toDTO(Notification n, Map<UUID, String> titles) {
|
||||||
|
return new NotificationDTO(
|
||||||
|
n.getId(),
|
||||||
|
n.getType(),
|
||||||
|
n.getDocumentId(),
|
||||||
|
n.getReferenceId(),
|
||||||
|
n.getAnnotationId(),
|
||||||
|
n.isRead(),
|
||||||
|
n.getCreatedAt(),
|
||||||
|
n.getActorName(),
|
||||||
|
n.getDocumentId() != null ? titles.get(n.getDocumentId()) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void buildCommentPath(DocumentComment comment, StringBuilder sb) {
|
||||||
|
sb.append("?commentId=").append(comment.getId());
|
||||||
|
if (comment.getAnnotationId() != null) {
|
||||||
|
sb.append("&annotationId=").append(comment.getAnnotationId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendNotificationEmail(AppUser recipient, DocumentComment comment, NotificationType type) {
|
||||||
|
if (mailSender.isEmpty()) {
|
||||||
|
log.warn("Mail sender not configured — skipping notification email to {}", recipient.getEmail());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (recipient.getEmail() == null || recipient.getEmail().isBlank()) return;
|
||||||
|
|
||||||
|
StringBuilder path = new StringBuilder("/documents/").append(comment.getDocumentId());
|
||||||
|
buildCommentPath(comment, path);
|
||||||
|
String link = baseUrl + path;
|
||||||
|
|
||||||
|
String subject = type == NotificationType.REPLY
|
||||||
|
? "Neue Antwort auf deinen Kommentar — Familienarchiv"
|
||||||
|
: "Du wurdest in einem Kommentar erwähnt — Familienarchiv";
|
||||||
|
|
||||||
|
String body = type == NotificationType.REPLY
|
||||||
|
? "Hallo,\n\njemand hat auf einen Kommentar geantwortet, an dem du beteiligt warst.\n\n"
|
||||||
|
+ "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team"
|
||||||
|
: "Hallo,\n\njemand hat dich in einem Kommentar erwähnt.\n\n"
|
||||||
|
+ "Zum Kommentar:\n" + link + "\n\nDein Familienarchiv-Team";
|
||||||
|
|
||||||
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
|
message.setFrom(mailFrom);
|
||||||
|
message.setTo(recipient.getEmail());
|
||||||
|
message.setSubject(subject);
|
||||||
|
message.setText(body);
|
||||||
|
|
||||||
|
try {
|
||||||
|
mailSender.get().send(message);
|
||||||
|
} catch (MailException e) {
|
||||||
|
log.error("Failed to send notification email to {}: {}", recipient.getEmail(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
@@ -20,16 +23,19 @@ public class PersonService {
|
|||||||
|
|
||||||
private final PersonRepository personRepository;
|
private final PersonRepository personRepository;
|
||||||
|
|
||||||
public List<Person> findAll(String q) {
|
public List<PersonSummaryDTO> findAll(String q) {
|
||||||
if (q != null && !q.isBlank()) {
|
if (q == null) {
|
||||||
return personRepository.searchByName(q);
|
return personRepository.findAllWithDocumentCount();
|
||||||
}
|
}
|
||||||
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
|
if (q.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return personRepository.searchWithDocumentCount(q.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Person getById(UUID id) {
|
public Person getById(UUID id) {
|
||||||
return personRepository.findById(id)
|
return personRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<Person> findCorrespondents(UUID personId, String q) {
|
public List<Person> findCorrespondents(UUID personId, String q) {
|
||||||
@@ -71,12 +77,36 @@ public class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
public Person createPerson(PersonUpdateDTO dto) {
|
||||||
if (dto.getBirthYear() != null && dto.getDeathYear() != null && dto.getBirthYear() > dto.getDeathYear()) {
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
|
Person person = Person.builder()
|
||||||
|
.firstName(dto.getFirstName())
|
||||||
|
.lastName(dto.getLastName())
|
||||||
|
.alias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim())
|
||||||
|
.notes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim())
|
||||||
|
.birthYear(dto.getBirthYear())
|
||||||
|
.deathYear(dto.getDeathYear())
|
||||||
|
.build();
|
||||||
|
return personRepository.save(person);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateYears(Integer birthYear, Integer deathYear) {
|
||||||
|
if (birthYear != null && birthYear <= 0) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr muss eine positive Zahl sein");
|
||||||
|
}
|
||||||
|
if (deathYear != null && deathYear <= 0) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Todesjahr muss eine positive Zahl sein");
|
||||||
|
}
|
||||||
|
if (birthYear != null && deathYear != null && birthYear > deathYear) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Person updatePerson(UUID id, PersonUpdateDTO dto) {
|
||||||
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
person.setFirstName(dto.getFirstName());
|
person.setFirstName(dto.getFirstName());
|
||||||
person.setLastName(dto.getLastName());
|
person.setLastName(dto.getLastName());
|
||||||
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
person.setAlias(dto.getAlias() == null || dto.getAlias().isBlank() ? null : dto.getAlias().trim());
|
||||||
@@ -92,9 +122,9 @@ public class PersonService {
|
|||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Quelle und Ziel dürfen nicht identisch sein");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Quelle und Ziel dürfen nicht identisch sein");
|
||||||
}
|
}
|
||||||
personRepository.findById(sourceId)
|
personRepository.findById(sourceId)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Quell-Person nicht gefunden"));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Source person not found: " + sourceId));
|
||||||
personRepository.findById(targetId)
|
personRepository.findById(targetId)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ziel-Person nicht gefunden"));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Target person not found: " + targetId));
|
||||||
|
|
||||||
// Reassign sender references
|
// Reassign sender references
|
||||||
personRepository.reassignSender(sourceId, targetId);
|
personRepository.reassignSender(sourceId, targetId);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class SseEmitterRegistry {
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<UUID, SseEmitter> emitters = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public SseEmitter register(UUID userId) {
|
||||||
|
SseEmitter emitter = new SseEmitter(0L); // 0 = no timeout; EventSource reconnects automatically
|
||||||
|
emitters.put(userId, emitter);
|
||||||
|
emitter.onCompletion(() -> emitters.remove(userId, emitter));
|
||||||
|
emitter.onTimeout(() -> emitters.remove(userId, emitter));
|
||||||
|
emitter.onError(e -> emitters.remove(userId, emitter));
|
||||||
|
return emitter;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void send(UUID userId, Object data) {
|
||||||
|
SseEmitter emitter = emitters.get(userId);
|
||||||
|
if (emitter == null) return;
|
||||||
|
try {
|
||||||
|
emitter.send(SseEmitter.event().name("notification").data(data));
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("SSE send failed for user {} — removing emitter", userId);
|
||||||
|
emitters.remove(userId, emitter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||||
|
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class TranscriptionService {
|
||||||
|
|
||||||
|
private static final String TRANSCRIPTION_COLOR = "#00C7B1";
|
||||||
|
private static final int MAX_TEXT_LENGTH = 10_000;
|
||||||
|
|
||||||
|
private final TranscriptionBlockRepository blockRepository;
|
||||||
|
private final TranscriptionBlockVersionRepository versionRepository;
|
||||||
|
private final AnnotationRepository annotationRepository;
|
||||||
|
private final AnnotationService annotationService;
|
||||||
|
private final DocumentService documentService;
|
||||||
|
|
||||||
|
public List<TranscriptionBlock> listBlocks(UUID documentId) {
|
||||||
|
return blockRepository.findByDocumentIdOrderBySortOrderAsc(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TranscriptionBlock getBlock(UUID documentId, UUID blockId) {
|
||||||
|
return blockRepository.findByIdAndDocumentId(blockId, documentId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.TRANSCRIPTION_BLOCK_NOT_FOUND,
|
||||||
|
"Transcription block not found: " + blockId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public TranscriptionBlock createBlock(UUID documentId, CreateTranscriptionBlockDTO dto, UUID userId) {
|
||||||
|
Document doc = documentService.getDocumentById(documentId);
|
||||||
|
|
||||||
|
CreateAnnotationDTO annotationDTO = new CreateAnnotationDTO(
|
||||||
|
dto.getPageNumber(), dto.getX(), dto.getY(),
|
||||||
|
dto.getWidth(), dto.getHeight(), TRANSCRIPTION_COLOR);
|
||||||
|
DocumentAnnotation annotation = annotationService.createAnnotation(
|
||||||
|
documentId, annotationDTO, userId, doc.getFileHash());
|
||||||
|
|
||||||
|
int nextOrder = blockRepository.countByDocumentId(documentId);
|
||||||
|
String text = sanitizeText(dto.getText());
|
||||||
|
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.annotationId(annotation.getId())
|
||||||
|
.documentId(documentId)
|
||||||
|
.text(text)
|
||||||
|
.label(dto.getLabel())
|
||||||
|
.sortOrder(nextOrder)
|
||||||
|
.createdBy(userId)
|
||||||
|
.updatedBy(userId)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
TranscriptionBlock saved = blockRepository.save(block);
|
||||||
|
saveVersion(saved, userId);
|
||||||
|
log.info("Created transcription block {} for document {}", saved.getId(), documentId);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public TranscriptionBlock updateBlock(UUID documentId, UUID blockId,
|
||||||
|
UpdateTranscriptionBlockDTO dto, UUID userId) {
|
||||||
|
TranscriptionBlock block = getBlock(documentId, blockId);
|
||||||
|
|
||||||
|
String text = sanitizeText(dto.getText());
|
||||||
|
block.setText(text);
|
||||||
|
if (dto.getLabel() != null) {
|
||||||
|
block.setLabel(dto.getLabel());
|
||||||
|
}
|
||||||
|
block.setUpdatedBy(userId);
|
||||||
|
|
||||||
|
TranscriptionBlock saved = blockRepository.save(block);
|
||||||
|
saveVersion(saved, userId);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteBlock(UUID documentId, UUID blockId) {
|
||||||
|
TranscriptionBlock block = getBlock(documentId, blockId);
|
||||||
|
UUID annotationId = block.getAnnotationId();
|
||||||
|
|
||||||
|
// Block is the aggregate root — delete block first (cascades to versions + comments),
|
||||||
|
// then delete the dependent annotation directly (no ownership check needed)
|
||||||
|
blockRepository.delete(block);
|
||||||
|
blockRepository.flush();
|
||||||
|
annotationRepository.deleteById(annotationId);
|
||||||
|
log.info("Deleted transcription block {} and annotation {} for document {}",
|
||||||
|
blockId, annotationId, documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void reorderBlocks(UUID documentId, ReorderTranscriptionBlocksDTO dto) {
|
||||||
|
List<UUID> blockIds = dto.getBlockIds();
|
||||||
|
for (int i = 0; i < blockIds.size(); i++) {
|
||||||
|
TranscriptionBlock block = getBlock(documentId, blockIds.get(i));
|
||||||
|
block.setSortOrder(i);
|
||||||
|
blockRepository.save(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TranscriptionBlockVersion> getBlockHistory(UUID documentId, UUID blockId) {
|
||||||
|
getBlock(documentId, blockId);
|
||||||
|
return versionRepository.findByBlockIdOrderByChangedAtDesc(blockId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveVersion(TranscriptionBlock block, UUID userId) {
|
||||||
|
TranscriptionBlockVersion version = TranscriptionBlockVersion.builder()
|
||||||
|
.blockId(block.getId())
|
||||||
|
.text(block.getText())
|
||||||
|
.changedBy(userId)
|
||||||
|
.build();
|
||||||
|
versionRepository.save(version);
|
||||||
|
}
|
||||||
|
|
||||||
|
String sanitizeText(String text) {
|
||||||
|
if (text == null) return "";
|
||||||
|
if (text.length() > MAX_TEXT_LENGTH) {
|
||||||
|
text = text.substring(0, MAX_TEXT_LENGTH);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserSearchService {
|
||||||
|
|
||||||
|
private static final int MAX_RESULTS = 10;
|
||||||
|
|
||||||
|
private final AppUserRepository userRepository;
|
||||||
|
|
||||||
|
public List<AppUser> search(String query) {
|
||||||
|
if (query == null || query.isBlank()) return List.of();
|
||||||
|
return userRepository.searchByNameOrUsername(query.trim(), PageRequest.of(0, MAX_RESULTS));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import org.springframework.security.crypto.password.PasswordEncoder;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -78,6 +79,18 @@ public class UserService {
|
|||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.USER_NOT_FOUND, "No user found for id: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<AppUser> findAllById(Collection<UUID> ids) {
|
||||||
|
return userRepository.findAllById(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AppUser updateNotificationPreferences(UUID userId, boolean notifyOnReply, boolean notifyOnMention) {
|
||||||
|
AppUser user = getById(userId);
|
||||||
|
user.setNotifyOnReply(notifyOnReply);
|
||||||
|
user.setNotifyOnMention(notifyOnMention);
|
||||||
|
return userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
public AppUser updateProfile(UUID userId, UpdateProfileDTO dto) {
|
||||||
AppUser user = getById(userId);
|
AppUser user = getById(userId);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ spring:
|
|||||||
enabled: false # Managed explicitly via FlywayConfig bean
|
enabled: false # Managed explicitly via FlywayConfig bean
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
|
open-in-view: false # Prevents holding DB connections for the full HTTP request lifecycle
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: none
|
ddl-auto: none
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Notification preferences on the user record — no separate entity needed
|
||||||
|
ALTER TABLE users ADD COLUMN notify_on_reply BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE users ADD COLUMN notify_on_mention BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- In-app notifications
|
||||||
|
CREATE TABLE notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
recipient_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(32) NOT NULL, -- 'REPLY' | 'MENTION'
|
||||||
|
document_id UUID,
|
||||||
|
reference_id UUID, -- commentId that triggered this notification
|
||||||
|
annotation_id UUID,
|
||||||
|
read BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
actor_name VARCHAR(255)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_notifications_recipient ON notifications(recipient_id, read, created_at DESC);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE comment_mentions (
|
||||||
|
comment_id UUID NOT NULL REFERENCES document_comments(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (comment_id, user_id)
|
||||||
|
);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE transcription_blocks (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
annotation_id UUID NOT NULL REFERENCES document_annotations(id) ON DELETE RESTRICT,
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL DEFAULT '' CHECK (length(text) <= 10000),
|
||||||
|
label VARCHAR(200),
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tb_document_sort ON transcription_blocks(document_id, sort_order);
|
||||||
|
CREATE INDEX idx_tb_annotation ON transcription_blocks(annotation_id);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE transcription_block_versions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
block_id UUID NOT NULL REFERENCES transcription_blocks(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
changed_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_tbv_block ON transcription_block_versions(block_id, changed_at DESC);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE document_comments
|
||||||
|
ADD COLUMN block_id UUID REFERENCES transcription_blocks(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE INDEX idx_dc_block ON document_comments(block_id);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package org.raddatz.familienarchiv;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@Import(PostgresContainerConfig.class)
|
||||||
|
class ApplicationContextTest {
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
S3Client s3Client;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void contextLoads() {
|
||||||
|
// verifies that the Spring context starts successfully with all beans wired,
|
||||||
|
// Flyway migrations applied, and no configuration errors
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.raddatz.familienarchiv;
|
||||||
|
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
|
||||||
|
@TestConfiguration(proxyBeanMethods = false)
|
||||||
|
public class PostgresContainerConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
@ServiceConnection
|
||||||
|
PostgreSQLContainer<?> postgresContainer() {
|
||||||
|
return new PostgreSQLContainer<>("postgres:16-alpine");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -81,6 +81,29 @@ class AnnotationControllerTest {
|
|||||||
.andExpect(status().isForbidden());
|
.andExpect(status().isForbidden());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createAnnotation_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
|
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(ANNOTATION_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void deleteAnnotation_returns204_whenHasWriteAllPermission() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser(authorities = "ANNOTATE_ALL")
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
|
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
|
||||||
@@ -132,4 +155,51 @@ class AnnotationControllerTest {
|
|||||||
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── resolveUserId — unauthenticated / null user / exception branches ─────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_returns401_whenUnauthenticated_resolveUserIdReturnsNull() throws Exception {
|
||||||
|
// authentication == null → resolveUserId returns null
|
||||||
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(ANNOTATION_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void createAnnotation_resolvesNullUserId_whenUserServiceThrows() throws Exception {
|
||||||
|
// findByUsername throws → catch block → resolveUserId returns null
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
when(userService.findByUsername(any())).thenThrow(new RuntimeException("DB error"));
|
||||||
|
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
|
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(ANNOTATION_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void createAnnotation_resolvesNullUserId_whenUserServiceReturnsNull() throws Exception {
|
||||||
|
// findByUsername returns null → user != null = false → resolveUserId returns null
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
when(userService.findByUsername(any())).thenReturn(null);
|
||||||
|
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
|
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
|
||||||
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
|
when(annotationService.createAnnotation(any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(ANNOTATION_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class CommentControllerTest {
|
|||||||
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
void postDocumentComment_returns201_whenHasPermission() throws Exception {
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
@@ -89,6 +89,18 @@ class CommentControllerTest {
|
|||||||
.andExpect(jsonPath("$.content").value("Test comment"));
|
.andExpect(jsonPath("$.content").value("Test comment"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void postDocumentComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
|
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -104,7 +116,20 @@ class CommentControllerTest {
|
|||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||||
.authorName("Anna").content("Test comment").build();
|
.authorName("Anna").content("Test comment").build();
|
||||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void replyToComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).parentId(COMMENT_ID)
|
||||||
|
.authorName("Anna").content("Test comment").build();
|
||||||
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
@@ -163,6 +188,18 @@ class CommentControllerTest {
|
|||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void editComment_returns200_whenHasWriteAllPermission() throws Exception {
|
||||||
|
DocumentComment updated = DocumentComment.builder()
|
||||||
|
.id(COMMENT_ID).documentId(DOC_ID).authorName("Hans").content("Test comment").build();
|
||||||
|
when(commentService.editComment(any(), any(), any(), any())).thenReturn(updated);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
|
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -179,7 +216,20 @@ class CommentControllerTest {
|
|||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
.authorName("Hans").content("Test comment").build();
|
.authorName("Hans").content("Test comment").build();
|
||||||
when(commentService.postComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void postAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
|
.authorName("Hans").content("Test comment").build();
|
||||||
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
@@ -194,10 +244,65 @@ class CommentControllerTest {
|
|||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
||||||
when(commentService.replyToComment(any(), any(), any(), any())).thenReturn(saved);
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
.andExpect(status().isCreated());
|
.andExpect(status().isCreated());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void replyToAnnotationComment_returns201_whenHasWriteAllPermission() throws Exception {
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).annotationId(ANN_ID)
|
||||||
|
.parentId(COMMENT_ID).authorName("Anna").content("Test comment").build();
|
||||||
|
when(commentService.replyToComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── resolveUser — exception branch ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void postDocumentComment_stillSucceeds_whenUserServiceThrows() throws Exception {
|
||||||
|
// findByUsername throws → catch block in resolveUser → author null, saves anyway
|
||||||
|
when(userService.findByUsername(any())).thenThrow(new RuntimeException("DB error"));
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).content("Test comment").build();
|
||||||
|
when(commentService.postComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Block comment endpoints ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getBlockComments_returns200() throws Exception {
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
when(commentService.getCommentsForBlock(blockId)).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void postBlockComment_returns201() throws Exception {
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(DOC_ID).blockId(blockId).content("Nice").build();
|
||||||
|
when(commentService.postBlockComment(any(), any(), any(), any(), any())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/transcription-blocks/" + blockId + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.blockId").value(blockId.toString()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||||
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.DocumentVersion;
|
import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
@@ -25,6 +27,9 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
|
||||||
@@ -53,13 +58,32 @@ class DocumentControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void search_returns200_whenAuthenticated() throws Exception {
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any()))
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), any()))
|
||||||
.thenReturn(Collections.emptyList());
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/search"))
|
mockMvc.perform(get("/api/documents/search"))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_withStatusParam_passesItToService() throws Exception {
|
||||||
|
when(documentService.searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED)))
|
||||||
|
.thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("status", "REVIEWED"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(documentService).searchDocuments(any(), any(), any(), any(), any(), any(), eq(DocumentStatus.REVIEWED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_withInvalidStatus_returns400() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/search").param("status", "INVALID"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── POST /api/documents ─────────────────────────────────────────────────
|
// ─── POST /api/documents ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -213,6 +237,80 @@ class DocumentControllerTest {
|
|||||||
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
|
.andExpect(jsonPath("$.errors[0].code").value("UNSUPPORTED_FILE_TYPE"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/{id}/file ────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentFile_returns404_whenDocHasNoFilePath() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("Brief").build(); // filePath == null
|
||||||
|
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + id + "/file"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentFile_returns200_withContentTypeFromDoc() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("Brief")
|
||||||
|
.filePath("docs/brief.pdf").contentType("application/pdf")
|
||||||
|
.originalFilename("brief.pdf").build();
|
||||||
|
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||||
|
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
|
||||||
|
when(fileService.downloadFile("docs/brief.pdf"))
|
||||||
|
.thenReturn(new FileService.S3FileDownload(
|
||||||
|
new org.springframework.core.io.InputStreamResource(stream), "application/octet-stream"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + id + "/file"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentFile_returns200_withContentTypeFromStorage_whenDocContentTypeIsBlank() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("Brief")
|
||||||
|
.filePath("docs/brief.pdf").contentType(" ") // blank → falls back to storage type
|
||||||
|
.originalFilename("brief.pdf").build();
|
||||||
|
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||||
|
java.io.InputStream stream = new java.io.ByteArrayInputStream(new byte[]{1, 2, 3});
|
||||||
|
when(fileService.downloadFile("docs/brief.pdf"))
|
||||||
|
.thenReturn(new FileService.S3FileDownload(
|
||||||
|
new org.springframework.core.io.InputStreamResource(stream), "application/pdf"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + id + "/file"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentFile_returns404_whenStorageFileNotFound() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("Brief")
|
||||||
|
.filePath("docs/missing.pdf").contentType("application/pdf")
|
||||||
|
.originalFilename("missing.pdf").build();
|
||||||
|
when(documentService.getDocumentById(id)).thenReturn(doc);
|
||||||
|
when(fileService.downloadFile("docs/missing.pdf"))
|
||||||
|
.thenThrow(new FileService.StorageFileNotFoundException("not found"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + id + "/file"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/quick-upload — null/empty files ─────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void quickUpload_returnsEmptyResult_whenNoFilesPartProvided() throws Exception {
|
||||||
|
mockMvc.perform(multipart("/api/documents/quick-upload"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.created").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.updated").isEmpty())
|
||||||
|
.andExpect(jsonPath("$.errors").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
|
// ─── GET /api/documents/incomplete-count ─────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -241,16 +339,39 @@ class DocumentControllerTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@WithMockUser
|
@WithMockUser
|
||||||
void getIncomplete_returns200_withList() throws Exception {
|
void getIncomplete_returns200_withDTOList() throws Exception {
|
||||||
Document doc = Document.builder()
|
UUID id = UUID.randomUUID();
|
||||||
.id(UUID.randomUUID()).title("Unvollständig").originalFilename("scan.pdf").build();
|
IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig");
|
||||||
when(documentService.findIncompleteDocuments()).thenReturn(List.of(doc));
|
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
|
||||||
|
|
||||||
mockMvc.perform(get("/api/documents/incomplete"))
|
mockMvc.perform(get("/api/documents/incomplete"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||||
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
|
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getIncomplete_withSizeParam_passesItToService() throws Exception {
|
||||||
|
when(documentService.findIncompleteDocuments(5)).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete").param("size", "5"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(documentService).findIncompleteDocuments(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception {
|
||||||
|
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/incomplete"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(documentService).findIncompleteDocuments(10);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -285,6 +406,38 @@ class DocumentControllerTest {
|
|||||||
.andExpect(status().isNoContent());
|
.andExpect(status().isNoContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/recent-activity ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRecentActivity_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/recent-activity"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getRecentActivity_returnsOkWithDocuments() throws Exception {
|
||||||
|
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||||
|
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||||
|
when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/recent-activity").param("size", "5"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].title").value("Alpha"))
|
||||||
|
.andExpect(jsonPath("$[1].title").value("Beta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception {
|
||||||
|
when(documentService.getRecentActivity(5)).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/recent-activity"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(documentService).getRecentActivity(5);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.NotificationService;
|
||||||
|
import org.raddatz.familienarchiv.service.SseEmitterRegistry;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.http.MediaType.TEXT_EVENT_STREAM_VALUE;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||||
|
|
||||||
|
@WebMvcTest(NotificationController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class NotificationControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean NotificationService notificationService;
|
||||||
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean SseEmitterRegistry sseEmitterRegistry;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
private static final UUID USER_ID = UUID.randomUUID();
|
||||||
|
|
||||||
|
// ─── GET /api/notifications ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser")
|
||||||
|
void getNotifications_returns200_whenAuthenticatedWithNoPermissions() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNotifications_returns200WithList_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
NotificationDTO dto = new NotificationDTO(
|
||||||
|
UUID.randomUUID(), NotificationType.REPLY, UUID.randomUUID(),
|
||||||
|
UUID.randomUUID(), null, false, LocalDateTime.now(), "Anna Smith", "Testdokument");
|
||||||
|
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(dto), PageRequest.of(0, 10), 1));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.content").isArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNotifications_returnsOnlyCurrentUsersNotifications() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(notificationService).getNotifications(eq(USER_ID), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNotifications_withTypeAndReadFalse_passesFiltersToService() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(notificationService.getNotifications(eq(USER_ID), eq(NotificationType.MENTION), eq(false), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications")
|
||||||
|
.param("type", "MENTION")
|
||||||
|
.param("read", "false"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
|
||||||
|
verify(notificationService).getNotifications(eq(USER_ID), eq(NotificationType.MENTION), eq(false), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNotifications_withInvalidType_returns400() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications").param("type", "INVALID_TYPE"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNotifications_returns400_whenSizeExceedsMaximum() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications").param("size", "200"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getNotifications_returns400_whenSizeIsZero() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications").param("size", "0"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/notifications/read-all ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/notifications/read-all"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void markAllRead_returns204_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/notifications/read-all"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
verify(notificationService).markAllRead(USER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/notifications/{id}/read ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markOneRead_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/notifications/" + UUID.randomUUID() + "/read"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void markOneRead_returns403_whenNotificationBelongsToDifferentUser() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
UUID notifId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
org.mockito.Mockito.doThrow(
|
||||||
|
org.raddatz.familienarchiv.exception.DomainException.forbidden("not yours"))
|
||||||
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/users/me/notification-preferences ──────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPreferences_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser")
|
||||||
|
void getPreferences_returns403_whenUserHasNoPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void getPreferences_returns200_whenUserHasReadAll() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(true).notifyOnMention(false).build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.notifyOnReply").value(true))
|
||||||
|
.andExpect(jsonPath("$.notifyOnMention").value(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void getPreferences_returns200_whenUserHasWriteAll() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(false).notifyOnMention(true).build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.notifyOnMention").value(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"ANNOTATE_ALL"})
|
||||||
|
void getPreferences_returns200_whenUserHasAnnotateAll() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/me/notification-preferences"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void getNotifications_returns200_whenUserHasOnlyWriteAll() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(notificationService.getNotifications(eq(USER_ID), any(), any(), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of()));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PUT /api/users/me/notification-preferences ──────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void updatePreferences_persistsBothBooleans() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
|
||||||
|
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(true).notifyOnMention(true).build();
|
||||||
|
when(notificationService.updatePreferences(USER_ID, true, true)).thenReturn(updated);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":true}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.notifyOnReply").value(true))
|
||||||
|
.andExpect(jsonPath("$.notifyOnMention").value(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"WRITE_ALL"})
|
||||||
|
void updatePreferences_returns200_whenUserHasWriteAll() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
|
||||||
|
AppUser updated = AppUser.builder().id(USER_ID).username("testuser")
|
||||||
|
.notifyOnReply(true).notifyOnMention(false).build();
|
||||||
|
when(notificationService.updatePreferences(USER_ID, true, false)).thenReturn(updated);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/users/me/notification-preferences")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"notifyOnReply\":true,\"notifyOnMention\":false}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.notifyOnReply").value(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/notifications/unread-count ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countUnread_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications/unread-count"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void countUnread_returns200WithCount_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(notificationService.countUnread(USER_ID)).thenReturn(3L);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications/unread-count"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.count").value(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/notifications/{id}/read — additional cases ───────────────
|
||||||
|
|
||||||
|
// ─── GET /api/notifications/stream ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stream_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/notifications/stream")
|
||||||
|
.accept(TEXT_EVENT_STREAM_VALUE))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void stream_returns200_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
when(sseEmitterRegistry.register(USER_ID)).thenReturn(new org.springframework.web.servlet.mvc.method.annotation.SseEmitter());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/notifications/stream")
|
||||||
|
.accept(TEXT_EVENT_STREAM_VALUE))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/notifications/{id}/read — additional cases ───────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "testuser", authorities = {"READ_ALL"})
|
||||||
|
void markOneRead_returns404_whenNotificationDoesNotExist() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(USER_ID).username("testuser").build();
|
||||||
|
UUID notifId = UUID.randomUUID();
|
||||||
|
|
||||||
|
when(userService.findByUsername("testuser")).thenReturn(user);
|
||||||
|
doThrow(DomainException.notFound(ErrorCode.NOTIFICATION_NOT_FOUND, "Notification not found: " + notifId))
|
||||||
|
.when(notificationService).markRead(notifId, USER_ID);
|
||||||
|
|
||||||
|
mockMvc.perform(patch("/api/notifications/" + notifId + "/read"))
|
||||||
|
.andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package org.raddatz.familienarchiv.controller;
|
|||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.security.PermissionAspect;
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
import org.raddatz.familienarchiv.service.DocumentService;
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
@@ -11,15 +12,22 @@ import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
|||||||
import org.raddatz.familienarchiv.config.SecurityConfig;
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.test.context.support.WithMockUser;
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@WebMvcTest(PersonController.class)
|
@WebMvcTest(PersonController.class)
|
||||||
@@ -32,6 +40,114 @@ class PersonControllerTest {
|
|||||||
@MockitoBean DocumentService documentService;
|
@MockitoBean DocumentService documentService;
|
||||||
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
// ─── GET /api/persons ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPersons_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getPersons_returns200_withEmptyList() throws Exception {
|
||||||
|
when(personService.findAll(null)).thenReturn(Collections.emptyList());
|
||||||
|
mockMvc.perform(get("/api/persons"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getPersons_delegatesQueryParam_toService() throws Exception {
|
||||||
|
PersonSummaryDTO dto = mockPersonSummary("Hans", "Müller");
|
||||||
|
when(personService.findAll("Hans")).thenReturn(List.of(dto));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons").param("q", "Hans"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private PersonSummaryDTO mockPersonSummary(String firstName, String lastName) {
|
||||||
|
return new PersonSummaryDTO() {
|
||||||
|
public java.util.UUID getId() { return UUID.randomUUID(); }
|
||||||
|
public String getFirstName() { return firstName; }
|
||||||
|
public String getLastName() { return lastName; }
|
||||||
|
public String getAlias() { return null; }
|
||||||
|
public Integer getBirthYear() { return null; }
|
||||||
|
public Integer getDeathYear() { return null; }
|
||||||
|
public String getNotes() { return null; }
|
||||||
|
public long getDocumentCount() { return 0; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/persons/{id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons/{id}", UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getPerson_returns200_whenFound() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Schmidt").build();
|
||||||
|
when(personService.getById(id)).thenReturn(person);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons/{id}", id))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.firstName").value("Anna"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/persons/{id}/correspondents ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCorrespondents_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/correspondents", UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getCorrespondents_returns200_withoutFilter() throws Exception {
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
when(personService.findCorrespondents(personId, null)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/correspondents", personId))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getCorrespondents_returns200_withFilter() throws Exception {
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
Person correspondent = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Gruyter").build();
|
||||||
|
when(personService.findCorrespondents(personId, "Walter")).thenReturn(List.of(correspondent));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/correspondents", personId).param("q", "Walter"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].firstName").value("Walter"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/persons/{id}/documents ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getPersonDocuments_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/documents", UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getPersonDocuments_returns200_whenAuthenticated() throws Exception {
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
when(documentService.getDocumentsBySender(personId)).thenReturn(Collections.emptyList());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/persons/{id}/documents", personId))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /api/persons/{id}/received-documents ─────────────────────────────
|
// ─── GET /api/persons/{id}/received-documents ─────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -49,4 +165,232 @@ class PersonControllerTest {
|
|||||||
mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
|
mockMvc.perform(get("/api/persons/{id}/received-documents", personId))
|
||||||
.andExpect(status().isOk());
|
.andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/persons ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns400_whenFirstNameIsMissing() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns400_whenFirstNameIsBlank() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\" \",\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns400_whenLastNameIsMissing() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns200_whenValid() throws Exception {
|
||||||
|
Person saved = Person.builder().id(UUID.randomUUID()).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.firstName").value("Hans"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PUT /api/persons/{id} ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns400_whenFirstNameIsBlank() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"\",\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns400_whenLastNameIsNull() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns200_whenValid() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person updated = Person.builder().id(id).firstName("Hans").lastName("Müller").build();
|
||||||
|
when(personService.updatePerson(eq(id), any())).thenReturn(updated);
|
||||||
|
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.lastName").value("Müller"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/persons/{id}/merge ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mergePerson_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void mergePerson_returns400_whenTargetPersonIdIsMissing() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void mergePerson_returns400_whenTargetPersonIdIsBlank() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetPersonId\":\" \"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void mergePerson_returns204_whenValid() throws Exception {
|
||||||
|
UUID sourceId = UUID.randomUUID();
|
||||||
|
UUID targetId = UUID.randomUUID();
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/merge", sourceId)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetPersonId\":\"" + targetId + "\"}"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PUT /api/persons/{id} — lastName blank branch ────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns400_whenLastNameIsBlank() throws Exception {
|
||||||
|
// firstName valid, lastName blank → second || operand = true → 400
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\" \"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Phase 2.2: POST /api/persons with full PersonUpdateDTO ───────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void createPerson_returns200_withAllSixFields() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person saved = Person.builder().id(id).firstName("Maria").lastName("Raddatz")
|
||||||
|
.alias("Oma Maria").birthYear(1901).deathYear(1975).notes("Some notes").build();
|
||||||
|
when(personService.createPerson(any(org.raddatz.familienarchiv.dto.PersonUpdateDTO.class))).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Maria\",\"lastName\":\"Raddatz\"," +
|
||||||
|
"\"alias\":\"Oma Maria\",\"birthYear\":1901,\"deathYear\":1975," +
|
||||||
|
"\"notes\":\"Some notes\"}"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.firstName").value("Maria"))
|
||||||
|
.andExpect(jsonPath("$.alias").value("Oma Maria"))
|
||||||
|
.andExpect(jsonPath("$.birthYear").value(1901));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Phase 1.2: @Size constraints ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns400_whenNotesExceed5000Chars() throws Exception {
|
||||||
|
String oversizedNotes = "x".repeat(5001);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\",\"notes\":\"" + oversizedNotes + "\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "WRITE_ALL")
|
||||||
|
void updatePerson_returns400_whenFirstNameExceeds100Chars() throws Exception {
|
||||||
|
String oversizedFirstName = "x".repeat(101);
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", id)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"" + oversizedFirstName + "\",\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Phase 1.1: @RequirePermission(WRITE_ALL) on write endpoints ──────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void createPerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void updatePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/persons/{id}", UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"firstName\":\"Hans\",\"lastName\":\"Müller\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "READ_ALL")
|
||||||
|
void mergePerson_returns403_whenUserHasOnlyReadPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/persons/{id}/merge", UUID.randomUUID())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"targetPersonId\":\"" + UUID.randomUUID() + "\"}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(StatsController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class StatsControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean PersonRepository personRepository;
|
||||||
|
@MockitoBean DocumentRepository documentRepository;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getStats_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/stats"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getStats_returns200_withCorrectCounts() throws Exception {
|
||||||
|
when(personRepository.count()).thenReturn(4L);
|
||||||
|
when(documentRepository.count()).thenReturn(12L);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/stats"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.totalPersons").value(4))
|
||||||
|
.andExpect(jsonPath("$.totalDocuments").value(12));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getStats_returns200_withZeroCounts() throws Exception {
|
||||||
|
when(personRepository.count()).thenReturn(0L);
|
||||||
|
when(documentRepository.count()).thenReturn(0L);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/stats"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.totalPersons").value(0))
|
||||||
|
.andExpect(jsonPath("$.totalDocuments").value(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(UserController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class UserControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
// ─── GET /api/users/me ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCurrentUser_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
// authentication == null → returns 401 (covers null/!isAuthenticated branch)
|
||||||
|
mockMvc.perform(get("/api/users/me"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "anna")
|
||||||
|
void getCurrentUser_returns200_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
|
when(userService.findByUsername("anna")).thenReturn(user);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/me"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.username").value("anna"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/users/{id} ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "reader")
|
||||||
|
void getUser_returns403_whenCallerLacksAdminUserPermission() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser target = AppUser.builder().id(id).username("target").build();
|
||||||
|
when(userService.getById(id)).thenReturn(target);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/" + id))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(username = "admin", authorities = {"ADMIN_USER"})
|
||||||
|
void getUser_returns200_whenCallerHasAdminUserPermission() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("target").build();
|
||||||
|
when(userService.getById(id)).thenReturn(user);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/" + id))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.username").value("target"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.UserSearchService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.security.test.context.support.WithMockUser;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.lessThanOrEqualTo;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(UserSearchController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class UserSearchControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean UserSearchService userSearchService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void search_returns403_whenUserLacksPermission() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = {"ANNOTATE_ALL"})
|
||||||
|
void search_returns200_whenUserHasAnnotateAll() throws Exception {
|
||||||
|
when(userSearchService.search("Hans")).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = {"READ_ALL"})
|
||||||
|
void search_returns200_whenAuthenticated() throws Exception {
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
||||||
|
.firstName("Hans").lastName("Mueller").username("hans").build();
|
||||||
|
when(userSearchService.search("Hans")).thenReturn(List.of(user));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", "Hans"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$[0].firstName").value("Hans"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = {"READ_ALL"})
|
||||||
|
void search_returnsEmptyList_whenQueryIsEmpty() throws Exception {
|
||||||
|
when(userSearchService.search("")).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", ""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = {"READ_ALL"})
|
||||||
|
void search_returnsAtMostTenResults() throws Exception {
|
||||||
|
List<AppUser> elevenUsers = IntStream.range(0, 11)
|
||||||
|
.mapToObj(i -> AppUser.builder().id(UUID.randomUUID())
|
||||||
|
.firstName("User").lastName(String.valueOf(i)).username("u" + i).build())
|
||||||
|
.toList();
|
||||||
|
when(userSearchService.search(anyString())).thenReturn(elevenUsers.subList(0, 10));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/users/search").param("q", "a"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.length()").value(lessThanOrEqualTo(10)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class DocumentRepositoryTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PersonRepository personRepository;
|
||||||
|
|
||||||
|
// ─── save and findById ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void save_persistsDocument_andFindByIdReturnsSameDocument() {
|
||||||
|
Document document = Document.builder()
|
||||||
|
.title("Testbrief")
|
||||||
|
.originalFilename("testbrief.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Document saved = documentRepository.save(document);
|
||||||
|
Optional<Document> found = documentRepository.findById(saved.getId());
|
||||||
|
|
||||||
|
assertThat(found).isPresent();
|
||||||
|
assertThat(found.get().getTitle()).isEqualTo("Testbrief");
|
||||||
|
assertThat(found.get().getStatus()).isEqualTo(DocumentStatus.PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByStatus ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByStatus_returnsOnlyDocumentsWithMatchingStatus() {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Placeholder Doc")
|
||||||
|
.originalFilename("placeholder.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.build());
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Uploaded Doc")
|
||||||
|
.originalFilename("uploaded.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
List<Document> placeholders = documentRepository.findByStatus(DocumentStatus.PLACEHOLDER);
|
||||||
|
|
||||||
|
assertThat(placeholders).extracting(Document::getStatus)
|
||||||
|
.containsOnly(DocumentStatus.PLACEHOLDER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByOriginalFilename ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByOriginalFilename_returnsDocument_whenFilenameMatches() {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Omas Brief")
|
||||||
|
.originalFilename("omas_brief.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
Optional<Document> found = documentRepository.findByOriginalFilename("omas_brief.pdf");
|
||||||
|
|
||||||
|
assertThat(found).isPresent();
|
||||||
|
assertThat(found.get().getTitle()).isEqualTo("Omas Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByOriginalFilename_returnsEmpty_whenFilenameDoesNotExist() {
|
||||||
|
Optional<Document> found = documentRepository.findByOriginalFilename("does_not_exist.pdf");
|
||||||
|
|
||||||
|
assertThat(found).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── existsByOriginalFilename ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsByOriginalFilename_returnsTrue_whenDocumentExists() {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief")
|
||||||
|
.originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
assertThat(documentRepository.existsByOriginalFilename("brief.pdf")).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void existsByOriginalFilename_returnsFalse_whenDocumentDoesNotExist() {
|
||||||
|
assertThat(documentRepository.existsByOriginalFilename("nonexistent.pdf")).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findBySenderId ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findBySenderId_returnsDocuments_whereSenderIdMatches() {
|
||||||
|
Person sender = personRepository.save(Person.builder()
|
||||||
|
.firstName("Hans")
|
||||||
|
.lastName("Müller")
|
||||||
|
.build());
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief von Hans")
|
||||||
|
.originalFilename("brief_hans.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
List<Document> docs = documentRepository.findBySenderId(sender.getId());
|
||||||
|
|
||||||
|
assertThat(docs).hasSize(1);
|
||||||
|
assertThat(docs.get(0).getSender().getId()).isEqualTo(sender.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── countByMetadataCompleteFalse ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countByMetadataCompleteFalse_returnsNumberOfIncompleteDocuments() {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Incomplete")
|
||||||
|
.originalFilename("incomplete.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.metadataComplete(false)
|
||||||
|
.build());
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Complete")
|
||||||
|
.originalFilename("complete.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.metadataComplete(true)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findAll (PageRequest) — recent activity ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_withPageRequest_returnsOnlySizeRows_notFullTable() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Doc " + i).originalFilename("doc" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<Document> result = documentRepository.findAll(
|
||||||
|
PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "updatedAt")));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(3);
|
||||||
|
assertThat(result.getTotalElements()).isEqualTo(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByMetadataCompleteFalse (Pageable) ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByMetadataCompleteFalse_withPageable_returnsOnlyIncompleteAndRespectsSizeCap() {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Incomplete " + i).originalFilename("inc" + i + ".pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).metadataComplete(false).build());
|
||||||
|
}
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Complete").originalFilename("complete.pdf")
|
||||||
|
.status(DocumentStatus.REVIEWED).metadataComplete(true).build());
|
||||||
|
|
||||||
|
Page<Document> result = documentRepository.findByMetadataCompleteFalse(
|
||||||
|
PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "createdAt")));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(3);
|
||||||
|
assertThat(result.getTotalElements()).isEqualTo(5);
|
||||||
|
assertThat(result.getContent()).allMatch(d -> !d.isMetadataComplete());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findSinglePersonCorrespondence — DISTINCT / multi-receiver safety ────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSinglePersonCorrespondence_returnsExactlyOneResult_whenDocumentHasThreeReceiversAndOneMatchesPersonId() {
|
||||||
|
Person sender = personRepository.save(Person.builder()
|
||||||
|
.firstName("Hans").lastName("Müller").build());
|
||||||
|
Person receiver1 = personRepository.save(Person.builder()
|
||||||
|
.firstName("Anna").lastName("Schmidt").build());
|
||||||
|
Person receiver2 = personRepository.save(Person.builder()
|
||||||
|
.firstName("Bertha").lastName("Wagner").build());
|
||||||
|
Person receiver3 = personRepository.save(Person.builder()
|
||||||
|
.firstName("Clara").lastName("Koch").build());
|
||||||
|
|
||||||
|
// Document addressed to all three receivers
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Rundschreiben")
|
||||||
|
.originalFilename("rundschreiben.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(Set.of(receiver1, receiver2, receiver3)))
|
||||||
|
.documentDate(LocalDate.of(1950, 6, 1))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||||
|
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||||
|
LocalDate to = LocalDate.of(2000, 1, 1);
|
||||||
|
|
||||||
|
// Query for receiver1 — the DISTINCT must collapse the 3 JOIN rows into 1 result
|
||||||
|
List<Document> results = documentRepository.findSinglePersonCorrespondence(
|
||||||
|
receiver1.getId(), from, to, sort);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0).getId()).isEqualTo(doc.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findSinglePersonCorrespondence_includesDocumentsWherePerson_isSender() {
|
||||||
|
Person sender = personRepository.save(Person.builder()
|
||||||
|
.firstName("Hans").lastName("Müller").build());
|
||||||
|
Person receiver = personRepository.save(Person.builder()
|
||||||
|
.firstName("Anna").lastName("Schmidt").build());
|
||||||
|
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief als Absender")
|
||||||
|
.originalFilename("brief_absender.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(new HashSet<>(Set.of(receiver)))
|
||||||
|
.documentDate(LocalDate.of(1950, 6, 1))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||||
|
LocalDate from = LocalDate.of(1900, 1, 1);
|
||||||
|
LocalDate to = LocalDate.of(2000, 1, 1);
|
||||||
|
|
||||||
|
List<Document> results = documentRepository.findSinglePersonCorrespondence(
|
||||||
|
sender.getId(), from, to, sort);
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class DocumentSpecificationsTest {
|
||||||
|
|
||||||
|
@Autowired DocumentRepository documentRepository;
|
||||||
|
@Autowired PersonRepository personRepository;
|
||||||
|
@Autowired TagRepository tagRepository;
|
||||||
|
|
||||||
|
private Person sender;
|
||||||
|
private Person receiver;
|
||||||
|
private Document briefEarly;
|
||||||
|
private Document briefLate;
|
||||||
|
private Document photoDoc;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
documentRepository.deleteAll();
|
||||||
|
personRepository.deleteAll();
|
||||||
|
tagRepository.deleteAll();
|
||||||
|
|
||||||
|
sender = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
|
||||||
|
receiver = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||||
|
|
||||||
|
Tag tagFamilie = tagRepository.save(Tag.builder().name("Familie").build());
|
||||||
|
Tag tagUrlaub = tagRepository.save(Tag.builder().name("Urlaub").build());
|
||||||
|
|
||||||
|
briefEarly = documentRepository.save(Document.builder()
|
||||||
|
.title("Alter Brief")
|
||||||
|
.originalFilename("brief_early.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.documentDate(LocalDate.of(1940, 5, 1))
|
||||||
|
.transcription("Liebe Anna, ich schreibe dir aus dem Krieg")
|
||||||
|
.location("Berlin")
|
||||||
|
.sender(sender)
|
||||||
|
.receivers(Set.of(receiver))
|
||||||
|
.tags(Set.of(tagFamilie))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
briefLate = documentRepository.save(Document.builder()
|
||||||
|
.title("Neuerer Brief")
|
||||||
|
.originalFilename("brief_late.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.documentDate(LocalDate.of(1960, 8, 15))
|
||||||
|
.sender(sender)
|
||||||
|
.tags(Set.of(tagUrlaub))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
photoDoc = documentRepository.save(Document.builder()
|
||||||
|
.title("Familienfoto")
|
||||||
|
.originalFilename("familienfoto.jpg")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── hasText ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_returnsAllDocuments_whenTextIsNull() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText(null)));
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_returnsAllDocuments_whenTextIsBlank() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText(" ")));
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_filtersOnTitle() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("familienfoto")));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_filtersOnOriginalFilename() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("brief_late")));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_filtersOnTranscription() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("schreibe dir")));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_filtersOnLocation() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("berlin")));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_isCaseInsensitive() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("BRIEF")));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasText_returnsEmpty_whenNoMatch() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasText("xyznotexist")));
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── hasSender ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasSender_returnsAllDocuments_whenPersonIdIsNull() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasSender(null)));
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasSender_filtersDocumentsBySender() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasSender(sender.getId())));
|
||||||
|
assertThat(result).extracting(Document::getTitle)
|
||||||
|
.containsExactlyInAnyOrder("Alter Brief", "Neuerer Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasSender_returnsEmpty_whenSenderHasNoDocuments() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasSender(receiver.getId())));
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── hasReceiver ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasReceiver_returnsAllDocuments_whenPersonIdIsNull() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasReceiver(null)));
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasReceiver_filtersDocumentsByReceiver() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasReceiver(receiver.getId())));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasReceiver_returnsEmpty_whenReceiverHasNoDocuments() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasReceiver(sender.getId())));
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── isBetween ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isBetween_returnsAllDocuments_whenBothDatesAreNull() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(isBetween(null, null)));
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isBetween_filtersByBothDates() {
|
||||||
|
List<Document> result = documentRepository.findAll(
|
||||||
|
Specification.where(isBetween(LocalDate.of(1939, 1, 1), LocalDate.of(1945, 12, 31))));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isBetween_filtersByStartDateOnly() {
|
||||||
|
List<Document> result = documentRepository.findAll(
|
||||||
|
Specification.where(isBetween(LocalDate.of(1950, 1, 1), null)));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Neuerer Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isBetween_filtersByEndDateOnly() {
|
||||||
|
List<Document> result = documentRepository.findAll(
|
||||||
|
Specification.where(isBetween(null, LocalDate.of(1945, 12, 31))));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void isBetween_returnsEmpty_whenNoDatesInRange() {
|
||||||
|
List<Document> result = documentRepository.findAll(
|
||||||
|
Specification.where(isBetween(LocalDate.of(1970, 1, 1), LocalDate.of(1980, 12, 31))));
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── hasTags ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTags_returnsAllDocuments_whenTagListIsNull() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTags(null)));
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTags_returnsAllDocuments_whenTagListIsEmpty() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of())));
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTags_filtersDocumentsByTag() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Familie"))));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTags_isCaseInsensitive() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("familie"))));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTags_requiresAllTagsToBePresent_andLogic() {
|
||||||
|
// briefEarly has "Familie" but not "Urlaub" — should be excluded
|
||||||
|
List<Document> result = documentRepository.findAll(
|
||||||
|
Specification.where(hasTags(List.of("Familie", "Urlaub"))));
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTags_skipsEmptyTagNames() {
|
||||||
|
// An empty string in the tag list should be ignored
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of(" ", "Familie"))));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Alter Brief");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasTags_returnsEmpty_whenTagDoesNotExist() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasTags(List.of("Unbekannt"))));
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── hasStatus ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasStatus_returnsAllDocuments_whenStatusIsNull() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(null)));
|
||||||
|
assertThat(result).hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasStatus_returnsOnlyMatchingDocuments_whenStatusIsSet() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.PLACEHOLDER)));
|
||||||
|
assertThat(result).extracting(Document::getTitle).containsExactly("Familienfoto");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hasStatus_returnsEmpty_whenNoDocumentMatchesStatus() {
|
||||||
|
List<Document> result = documentRepository.findAll(Specification.where(hasStatus(DocumentStatus.REVIEWED)));
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.Notification;
|
||||||
|
import org.raddatz.familienarchiv.model.NotificationType;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class NotificationRepositoryTest {
|
||||||
|
|
||||||
|
@Autowired NotificationRepository notificationRepository;
|
||||||
|
@Autowired AppUserRepository appUserRepository;
|
||||||
|
|
||||||
|
private AppUser userA;
|
||||||
|
private AppUser userB;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
notificationRepository.deleteAll();
|
||||||
|
appUserRepository.deleteAll();
|
||||||
|
userA = appUserRepository.save(AppUser.builder().username("userA").password("pw").build());
|
||||||
|
userB = appUserRepository.save(AppUser.builder().username("userB").password("pw").build());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByRecipientIdAndTypeAndReadFalse ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsOnlyUnreadMentions_forTargetUser() {
|
||||||
|
notificationRepository.save(mention(userA, false)); // ✓ match
|
||||||
|
notificationRepository.save(mention(userA, true)); // read — excluded
|
||||||
|
notificationRepository.save(reply(userA, false)); // REPLY — excluded
|
||||||
|
notificationRepository.save(mention(userB, false)); // different user — excluded
|
||||||
|
|
||||||
|
Page<Notification> result = notificationRepository
|
||||||
|
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(1);
|
||||||
|
assertThat(result.getContent().get(0).getRecipient().getId()).isEqualTo(userA.getId());
|
||||||
|
assertThat(result.getContent().get(0).getType()).isEqualTo(NotificationType.MENTION);
|
||||||
|
assertThat(result.getContent().get(0).isRead()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsEmpty_whenAllMentionsAreRead() {
|
||||||
|
notificationRepository.save(mention(userA, true));
|
||||||
|
|
||||||
|
Page<Notification> result = notificationRepository
|
||||||
|
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void respectsSizeLimit() {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
notificationRepository.save(mention(userA, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
Page<Notification> result = notificationRepository
|
||||||
|
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
userA.getId(), NotificationType.MENTION, Pageable.ofSize(3));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(3);
|
||||||
|
assertThat(result.getTotalElements()).isEqualTo(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByRecipientIdAndType (without read filter) ──────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByType_returnsBothReadAndUnreadMentions() {
|
||||||
|
notificationRepository.save(mention(userA, false)); // unread
|
||||||
|
notificationRepository.save(mention(userA, true)); // read — should also be included
|
||||||
|
notificationRepository.save(reply(userA, false)); // REPLY — excluded
|
||||||
|
notificationRepository.save(mention(userB, false)); // different user — excluded
|
||||||
|
|
||||||
|
Page<Notification> result = notificationRepository
|
||||||
|
.findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||||
|
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(2);
|
||||||
|
assertThat(result.getContent()).allMatch(n -> n.getType() == NotificationType.MENTION);
|
||||||
|
assertThat(result.getContent()).allMatch(n -> n.getRecipient().getId().equals(userA.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private Notification mention(AppUser recipient, boolean read) {
|
||||||
|
return Notification.builder()
|
||||||
|
.recipient(recipient)
|
||||||
|
.type(NotificationType.MENTION)
|
||||||
|
.actorName("Tester")
|
||||||
|
.read(read)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Notification reply(AppUser recipient, boolean read) {
|
||||||
|
return Notification.builder()
|
||||||
|
.recipient(recipient)
|
||||||
|
.type(NotificationType.REPLY)
|
||||||
|
.actorName("Tester")
|
||||||
|
.read(read)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.PostgresContainerConfig;
|
||||||
|
import org.raddatz.familienarchiv.config.FlywayConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
|
||||||
|
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
|
|
||||||
|
import jakarta.persistence.EntityManager;
|
||||||
|
import jakarta.persistence.PersistenceContext;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@DataJpaTest
|
||||||
|
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||||
|
@Import({PostgresContainerConfig.class, FlywayConfig.class})
|
||||||
|
class PersonRepositoryTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PersonRepository personRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private DocumentRepository documentRepository;
|
||||||
|
|
||||||
|
@PersistenceContext
|
||||||
|
private EntityManager entityManager;
|
||||||
|
|
||||||
|
// ─── save and findById ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void save_persistsPerson_andFindByIdReturnsSamePerson() {
|
||||||
|
Person person = Person.builder()
|
||||||
|
.firstName("Anna")
|
||||||
|
.lastName("Schmidt")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Person saved = personRepository.save(person);
|
||||||
|
Optional<Person> found = personRepository.findById(saved.getId());
|
||||||
|
|
||||||
|
assertThat(found).isPresent();
|
||||||
|
assertThat(found.get().getFirstName()).isEqualTo("Anna");
|
||||||
|
assertThat(found.get().getLastName()).isEqualTo("Schmidt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── searchByName ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByName_findsByFirstName() {
|
||||||
|
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||||
|
personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||||
|
|
||||||
|
List<Person> results = personRepository.searchByName("Hans");
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0).getFirstName()).isEqualTo("Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByName_findsByLastName() {
|
||||||
|
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||||
|
personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||||
|
|
||||||
|
List<Person> results = personRepository.searchByName("Schmidt");
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
assertThat(results.get(0).getLastName()).isEqualTo("Schmidt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByName_isCaseInsensitive() {
|
||||||
|
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||||
|
|
||||||
|
List<Person> results = personRepository.searchByName("hans");
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchByName_findsByAlias() {
|
||||||
|
personRepository.save(Person.builder()
|
||||||
|
.firstName("Hans").lastName("Müller").alias("Opa Hans").build());
|
||||||
|
|
||||||
|
List<Person> results = personRepository.searchByName("Opa Hans");
|
||||||
|
|
||||||
|
assertThat(results).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findAllByOrderByLastNameAscFirstNameAsc ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllByOrderByLastNameAscFirstNameAsc_returnsSortedByLastNameThenFirstName() {
|
||||||
|
personRepository.save(Person.builder().firstName("Bernd").lastName("Ziegler").build());
|
||||||
|
personRepository.save(Person.builder().firstName("Anna").lastName("Müller").build());
|
||||||
|
personRepository.save(Person.builder().firstName("Clara").lastName("Müller").build());
|
||||||
|
|
||||||
|
List<Person> sorted = personRepository.findAllByOrderByLastNameAscFirstNameAsc();
|
||||||
|
|
||||||
|
assertThat(sorted).extracting(Person::getLastName)
|
||||||
|
.startsWith("Müller", "Müller");
|
||||||
|
assertThat(sorted.stream()
|
||||||
|
.filter(p -> p.getLastName().equals("Müller"))
|
||||||
|
.map(Person::getFirstName)
|
||||||
|
.toList())
|
||||||
|
.containsExactly("Anna", "Clara");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByAliasIgnoreCase ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByAliasIgnoreCase_returnsMatchingPerson() {
|
||||||
|
personRepository.save(Person.builder()
|
||||||
|
.firstName("Karl").lastName("Brandt").alias("Opa Karl").build());
|
||||||
|
|
||||||
|
Optional<Person> found = personRepository.findByAliasIgnoreCase("opa karl");
|
||||||
|
|
||||||
|
assertThat(found).isPresent();
|
||||||
|
assertThat(found.get().getFirstName()).isEqualTo("Karl");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByAliasIgnoreCase_returnsEmpty_whenAliasDoesNotMatch() {
|
||||||
|
Optional<Person> found = personRepository.findByAliasIgnoreCase("nobody");
|
||||||
|
|
||||||
|
assertThat(found).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findByFirstNameIgnoreCaseAndLastNameIgnoreCase ───────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findByFirstNameIgnoreCaseAndLastNameIgnoreCase_returnsMatch() {
|
||||||
|
personRepository.save(Person.builder().firstName("Maria").lastName("Raddatz").build());
|
||||||
|
|
||||||
|
Optional<Person> found = personRepository.findByFirstNameIgnoreCaseAndLastNameIgnoreCase(
|
||||||
|
"maria", "raddatz");
|
||||||
|
|
||||||
|
assertThat(found).isPresent();
|
||||||
|
assertThat(found.get().getFirstName()).isEqualTo("Maria");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findCorrespondents ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findCorrespondents_returnsPersonsWhoSharedDocumentsWith() {
|
||||||
|
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
|
||||||
|
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||||
|
Person clara = personRepository.save(Person.builder().firstName("Clara").lastName("Cram").build());
|
||||||
|
|
||||||
|
// Walter sends to Anna (1 document)
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief 1").originalFilename("brief1.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(walter).receivers(Set.of(anna)).build());
|
||||||
|
|
||||||
|
// Walter sends to Clara (2 documents — Clara should rank higher)
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief 2").originalFilename("brief2.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(walter).receivers(Set.of(clara)).build());
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief 3").originalFilename("brief3.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(walter).receivers(Set.of(clara)).build());
|
||||||
|
|
||||||
|
List<Person> correspondents = personRepository.findCorrespondents(walter.getId());
|
||||||
|
|
||||||
|
assertThat(correspondents).extracting(Person::getFirstName)
|
||||||
|
.containsExactly("Clara", "Anna"); // Clara ranks first (2 documents)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findCorrespondents_returnsEmpty_whenPersonHasNoDocuments() {
|
||||||
|
Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build());
|
||||||
|
|
||||||
|
List<Person> correspondents = personRepository.findCorrespondents(solo.getId());
|
||||||
|
|
||||||
|
assertThat(correspondents).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── findCorrespondentsWithFilter ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findCorrespondentsWithFilter_returnsOnlyMatchingCorrespondents() {
|
||||||
|
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
|
||||||
|
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||||
|
Person bernd = personRepository.save(Person.builder().firstName("Bernd").lastName("Braun").build());
|
||||||
|
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief an Anna").originalFilename("anna.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(walter).receivers(Set.of(anna)).build());
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief an Bernd").originalFilename("bernd.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(walter).receivers(Set.of(bernd)).build());
|
||||||
|
|
||||||
|
List<Person> filtered = personRepository.findCorrespondentsWithFilter(walter.getId(), "Anna");
|
||||||
|
|
||||||
|
assertThat(filtered).extracting(Person::getFirstName).containsExactly("Anna");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findCorrespondentsWithFilter_isCaseInsensitive() {
|
||||||
|
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
|
||||||
|
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||||
|
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(walter).receivers(Set.of(anna)).build());
|
||||||
|
|
||||||
|
List<Person> filtered = personRepository.findCorrespondentsWithFilter(walter.getId(), "schmidt");
|
||||||
|
|
||||||
|
assertThat(filtered).hasSize(1);
|
||||||
|
assertThat(filtered.get(0).getLastName()).isEqualTo("Schmidt");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── reassignSender ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reassignSender_updatesDocumentsSenderFromSourceToTarget() {
|
||||||
|
Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build());
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build());
|
||||||
|
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(source).build());
|
||||||
|
|
||||||
|
personRepository.reassignSender(source.getId(), target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
List<Document> docs = documentRepository.findBySenderId(target.getId());
|
||||||
|
assertThat(docs).hasSize(1);
|
||||||
|
assertThat(documentRepository.findBySenderId(source.getId())).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── insertMissingReceiverReference ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void insertMissingReceiverReference_addsTargetWhereSourceWasReceiver() {
|
||||||
|
Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build());
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build());
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||||
|
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender).receivers(Set.of(source)).build());
|
||||||
|
|
||||||
|
personRepository.insertMissingReceiverReference(source.getId(), target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||||
|
assertThat(reloaded.getReceivers())
|
||||||
|
.extracting(Person::getId)
|
||||||
|
.contains(target.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void insertMissingReceiverReference_doesNotCreateDuplicate_whenTargetAlreadyReceiver() {
|
||||||
|
Person source = personRepository.save(Person.builder().firstName("Alt").lastName("Person").build());
|
||||||
|
Person target = personRepository.save(Person.builder().firstName("Neu").lastName("Person").build());
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||||
|
|
||||||
|
// target is already a receiver together with source
|
||||||
|
Document doc = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender).receivers(Set.of(source, target)).build());
|
||||||
|
|
||||||
|
personRepository.insertMissingReceiverReference(source.getId(), target.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
Document reloaded = documentRepository.findById(doc.getId()).orElseThrow();
|
||||||
|
long targetCount = reloaded.getReceivers().stream()
|
||||||
|
.filter(p -> p.getId().equals(target.getId())).count();
|
||||||
|
assertThat(targetCount).isEqualTo(1); // no duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Phase 3.2: findAllWithDocumentCount ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllWithDocumentCount_includesDocumentCountAsSenderAndReceiver() {
|
||||||
|
Person walter = personRepository.save(Person.builder().firstName("Walter").lastName("Müller").build());
|
||||||
|
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||||
|
|
||||||
|
// Walter sends 2 docs to Anna (Anna receives 2)
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief 1").originalFilename("b1.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(walter).receivers(Set.of(anna)).build());
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief 2").originalFilename("b2.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(walter).receivers(Set.of(anna)).build());
|
||||||
|
// Anna also sends 1 doc to Walter
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief 3").originalFilename("b3.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(anna).receivers(Set.of(walter)).build());
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();
|
||||||
|
|
||||||
|
PersonSummaryDTO walterSummary = result.stream()
|
||||||
|
.filter(p -> p.getId().equals(walter.getId())).findFirst().orElseThrow();
|
||||||
|
PersonSummaryDTO annaSummary = result.stream()
|
||||||
|
.filter(p -> p.getId().equals(anna.getId())).findFirst().orElseThrow();
|
||||||
|
|
||||||
|
assertThat(walterSummary.getDocumentCount()).isEqualTo(3); // sent 2, received 1
|
||||||
|
assertThat(annaSummary.getDocumentCount()).isEqualTo(3); // sent 1, received 2
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAllWithDocumentCount_returnsZero_whenPersonHasNoDocuments() {
|
||||||
|
Person solo = personRepository.save(Person.builder().firstName("Solo").lastName("Mensch").build());
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> result = personRepository.findAllWithDocumentCount();
|
||||||
|
|
||||||
|
PersonSummaryDTO soloSummary = result.stream()
|
||||||
|
.filter(p -> p.getId().equals(solo.getId())).findFirst().orElseThrow();
|
||||||
|
assertThat(soloSummary.getDocumentCount()).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchWithDocumentCount_filtersAndIncludesCount() {
|
||||||
|
Person hans = personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||||
|
Person anna = personRepository.save(Person.builder().firstName("Anna").lastName("Schmidt").build());
|
||||||
|
|
||||||
|
documentRepository.save(Document.builder()
|
||||||
|
.title("Brief").originalFilename("brief.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(hans).receivers(Set.of(anna)).build());
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("Hans");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getFirstName()).isEqualTo("Hans");
|
||||||
|
assertThat(result.get(0).getDocumentCount()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchWithDocumentCount_isCaseInsensitive() {
|
||||||
|
personRepository.save(Person.builder().firstName("Hans").lastName("Müller").build());
|
||||||
|
|
||||||
|
List<PersonSummaryDTO> result = personRepository.searchWithDocumentCount("hans");
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── deleteReceiverReferences ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteReceiverReferences_removesPersonFromAllDocumentReceivers() {
|
||||||
|
Person toDelete = personRepository.save(Person.builder().firstName("Weg").lastName("Person").build());
|
||||||
|
Person sender = personRepository.save(Person.builder().firstName("Send").lastName("Er").build());
|
||||||
|
|
||||||
|
Document doc1 = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief 1").originalFilename("b1.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender).receivers(Set.of(toDelete)).build());
|
||||||
|
Document doc2 = documentRepository.save(Document.builder()
|
||||||
|
.title("Brief 2").originalFilename("b2.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.sender(sender).receivers(Set.of(toDelete)).build());
|
||||||
|
|
||||||
|
personRepository.deleteReceiverReferences(toDelete.getId());
|
||||||
|
entityManager.flush();
|
||||||
|
entityManager.clear();
|
||||||
|
|
||||||
|
assertThat(documentRepository.findById(doc1.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||||
|
assertThat(documentRepository.findById(doc2.getId()).orElseThrow().getReceivers()).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -183,4 +183,100 @@ class AnnotationServiceTest {
|
|||||||
|
|
||||||
verify(annotationRepository, never()).save(any());
|
verify(annotationRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── deleteAnnotation — null userId ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteAnnotation_throwsForbidden_whenUserIdIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID annotId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
|
||||||
|
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||||
|
.id(annotId).documentId(docId).createdBy(ownerId).build();
|
||||||
|
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
|
||||||
|
.thenReturn(Optional.of(annotation));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, null))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── overlaps — partial overlap cases ────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_noConflict_whenAnnotationIsToTheLeft() {
|
||||||
|
// existing: x=0.5, w=0.3 (x2=0.8); dto: x=0.0, w=0.4 (dx2=0.4)
|
||||||
|
// existing.getX() < dx2 → 0.5 < 0.4 → FALSE → no overlap (first && fails)
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
DocumentAnnotation existing = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
|
.x(0.5).y(0.0).width(0.3).height(0.5).color("#ff0000").build();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.4, 0.5, "#0000ff");
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
|
||||||
|
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
|
||||||
|
|
||||||
|
verify(annotationRepository).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_noConflict_whenAnnotationIsToTheRight() {
|
||||||
|
// existing: x=0.0, w=0.1 (ex2=0.1); dto: x=0.2, w=0.3 (dx2=0.5)
|
||||||
|
// existing.getX() < dx2 → 0.0 < 0.5 → TRUE
|
||||||
|
// ex2 > dto.getX() → 0.1 > 0.2 → FALSE → no overlap (second && fails)
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
DocumentAnnotation existing = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
|
.x(0.0).y(0.0).width(0.1).height(0.5).color("#ff0000").build();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.2, 0.0, 0.3, 0.5, "#0000ff");
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
|
||||||
|
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
|
||||||
|
|
||||||
|
verify(annotationRepository).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_noConflict_whenAnnotationIsBelow() {
|
||||||
|
// x ranges overlap, but y ranges don't
|
||||||
|
// existing: x=0.0, w=0.5, y=0.5, h=0.2 (ey2=0.7)
|
||||||
|
// dto: x=0.1, w=0.3 (dx2=0.4), y=0.0, h=0.4 (dy2=0.4)
|
||||||
|
// existing.getX() < dx2 → 0.0 < 0.4 → TRUE
|
||||||
|
// ex2 > dto.getX() → 0.5 > 0.1 → TRUE
|
||||||
|
// existing.getY() < dy2 → 0.5 < 0.4 → FALSE → no overlap (third && fails)
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
DocumentAnnotation existing = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
|
.x(0.0).y(0.5).width(0.5).height(0.2).color("#ff0000").build();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.0, 0.3, 0.4, "#0000ff");
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
|
||||||
|
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
|
||||||
|
|
||||||
|
verify(annotationRepository).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_noConflict_whenAnnotationIsAbove() {
|
||||||
|
// x ranges overlap, y ranges don't — existing is ABOVE the new annotation
|
||||||
|
// existing: x=0.0, w=0.5, y=0.0, h=0.1 (ey2=0.1)
|
||||||
|
// dto: x=0.1, w=0.3 (dx2=0.4), y=0.2, h=0.3 (dy2=0.5)
|
||||||
|
// A: 0.0 < 0.4 → TRUE, B: 0.5 > 0.1 → TRUE, C: 0.0 < 0.5 → TRUE
|
||||||
|
// D: ey2 > dto.getY() → 0.1 > 0.2 → FALSE → no overlap (fourth && fails)
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
DocumentAnnotation existing = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
|
.x(0.0).y(0.0).width(0.5).height(0.1).color("#ff0000").build();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.2, 0.3, 0.3, "#0000ff");
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of(existing));
|
||||||
|
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
annotationService.createAnnotation(docId, dto, UUID.randomUUID(), null);
|
||||||
|
|
||||||
|
verify(annotationRepository).save(any());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import java.util.UUID;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anySet;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
@@ -30,6 +33,8 @@ import static org.springframework.http.HttpStatus.NOT_FOUND;
|
|||||||
class CommentServiceTest {
|
class CommentServiceTest {
|
||||||
|
|
||||||
@Mock CommentRepository commentRepository;
|
@Mock CommentRepository commentRepository;
|
||||||
|
@Mock UserService userService;
|
||||||
|
@Mock NotificationService notificationService;
|
||||||
@InjectMocks CommentService commentService;
|
@InjectMocks CommentService commentService;
|
||||||
|
|
||||||
// ─── postComment ──────────────────────────────────────────────────────────
|
// ─── postComment ──────────────────────────────────────────────────────────
|
||||||
@@ -43,7 +48,7 @@ class CommentServiceTest {
|
|||||||
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||||
}
|
}
|
||||||
@@ -56,11 +61,28 @@ class CommentServiceTest {
|
|||||||
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
DocumentComment result = commentService.postComment(docId, null, "Test", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID mentionedId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("M").build();
|
||||||
|
AppUser mentioned = AppUser.builder().id(mentionedId).username("anna").firstName("Anna").lastName("S").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hey @Anna S").build();
|
||||||
|
|
||||||
|
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hey @Anna S", List.of(mentionedId), author);
|
||||||
|
|
||||||
|
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── replyToComment ───────────────────────────────────────────────────────
|
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -70,7 +92,7 @@ class CommentServiceTest {
|
|||||||
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
|
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", List.of(), author))
|
||||||
.isInstanceOf(DomainException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||||
|
|
||||||
@@ -91,11 +113,12 @@ class CommentServiceTest {
|
|||||||
|
|
||||||
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
||||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply));
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
|
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||||
}
|
}
|
||||||
@@ -110,15 +133,59 @@ class CommentServiceTest {
|
|||||||
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||||
|
|
||||||
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||||
DocumentComment saved = DocumentComment.builder()
|
DocumentComment saved = DocumentComment.builder()
|
||||||
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
when(commentRepository.save(any())).thenReturn(saved);
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author);
|
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
|
||||||
|
|
||||||
assertThat(result.getParentId()).isEqualTo(rootId);
|
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_triggersNotifyReply_afterSave() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
|
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
|
||||||
|
|
||||||
|
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_triggersNotifyMentions_whenMentionedUserIdsProvided() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
UUID mentionedId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
|
AppUser mentioned = AppUser.builder().id(mentionedId).username("bob").firstName("Bob").lastName("J").build();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).parentId(null).content("Root").authorName("Hans").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Hey @Bob J").authorName("anna").build();
|
||||||
|
|
||||||
|
when(userService.findAllById(List.of(mentionedId))).thenReturn(List.of(mentioned));
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.replyToComment(docId, rootId, "Hey @Bob J", List.of(mentionedId), author);
|
||||||
|
|
||||||
|
verify(notificationService).notifyMentions(eq(List.of(mentionedId)), eq(saved));
|
||||||
|
}
|
||||||
|
|
||||||
// ─── editComment ──────────────────────────────────────────────────────────
|
// ─── editComment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -233,6 +300,181 @@ class CommentServiceTest {
|
|||||||
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── replyToComment — reply with null authorId in thread ─────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_handlesNullAuthorId_inExistingReply() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").firstName("Anna").lastName("S").build();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID()).content("Root").authorName("Root").build();
|
||||||
|
// Existing reply with null authorId
|
||||||
|
DocumentComment nullAuthorReply = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorId(null).content("Anon reply").authorName("anon").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("New reply").authorName("Anna S").build();
|
||||||
|
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(nullAuthorReply));
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.replyToComment(docId, rootId, "New reply", List.of(), author);
|
||||||
|
|
||||||
|
// Must not throw NullPointerException; only non-null authorIds collected
|
||||||
|
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── resolveAuthorName edge cases ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_fallsBackToUsername_whenFirstNameBlankAndLastNameNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
|
||||||
|
.firstName(" ").lastName(null).build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("user42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_fallsBackToUsername_whenFirstNameNullAndLastNameBlank() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
|
||||||
|
.firstName(null).lastName(" ").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("user42").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("user42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_includesOnlyFirstName_whenLastNameIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
|
||||||
|
.firstName("Hans").lastName(null).build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
// first != null && !blank → true; last == null → entire condition false → returns stripped first
|
||||||
|
verify(commentRepository).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_includesOnlyLastName_whenFirstNameIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("user42")
|
||||||
|
.firstName(null).lastName("Müller").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Müller").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hi", List.of(), author);
|
||||||
|
|
||||||
|
// No exception — name resolution with null first name strips cleanly
|
||||||
|
verify(commentRepository).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── saveMentions — null/empty guard ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_doesNotCallUserService_whenMentionedUserIdsIsNull() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans")
|
||||||
|
.firstName("Hans").lastName("M").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans M").content("Hi").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.postComment(docId, null, "Hi", null, author);
|
||||||
|
|
||||||
|
verify(userService, never()).findAllById(anyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── collectParticipantIds — non-null authorId in reply ──────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_includesNonNullAuthorId_fromExistingReply() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
UUID existingReplyAuthorId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).parentId(null).authorId(UUID.randomUUID())
|
||||||
|
.content("Root").authorName("root").build();
|
||||||
|
// Existing reply WITH a non-null authorId — covers true branch of reply.getAuthorId() != null
|
||||||
|
DocumentComment existingReply = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId)
|
||||||
|
.authorId(existingReplyAuthorId).content("Existing").authorName("someone").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId)
|
||||||
|
.content("New reply").authorName("anna").build();
|
||||||
|
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(existingReply));
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
commentService.replyToComment(docId, rootId, "New reply", List.of(), author);
|
||||||
|
|
||||||
|
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── collectParticipantIds — null authorId ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_excludesNullAuthorIds_fromParticipantSet() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
|
|
||||||
|
// Root with null authorId
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).parentId(null).authorId(null).content("Root").authorName("anon").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
|
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
// Must not throw NullPointerException
|
||||||
|
commentService.replyToComment(docId, rootId, "Reply", List.of(), author);
|
||||||
|
|
||||||
|
verify(notificationService).notifyReply(eq(saved), anySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getCommentsForAnnotation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCommentsForAnnotation_returnsRootsForAnnotation() {
|
||||||
|
UUID annotationId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).annotationId(annotationId).authorName("Hans").content("Root").build();
|
||||||
|
|
||||||
|
when(commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId))
|
||||||
|
.thenReturn(List.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<DocumentComment> result = commentService.getCommentsForAnnotation(annotationId);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getAnnotationId()).isEqualTo(annotationId);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private AppUser buildAdmin() {
|
private AppUser buildAdmin() {
|
||||||
@@ -246,4 +488,40 @@ class CommentServiceTest {
|
|||||||
.build()))
|
.build()))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Block-level comments ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCommentsForBlock_returnsRootCommentsFilteredByBlockId() {
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).blockId(blockId).content("Nice work").authorName("Felix")
|
||||||
|
.createdAt(LocalDateTime.now()).updatedAt(LocalDateTime.now()).build();
|
||||||
|
when(commentRepository.findByBlockIdAndParentIdIsNull(blockId)).thenReturn(List.of(root));
|
||||||
|
when(commentRepository.findByParentId(root.getId())).thenReturn(List.of());
|
||||||
|
|
||||||
|
List<DocumentComment> result = commentService.getCommentsForBlock(blockId);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.getFirst().getContent()).isEqualTo("Nice work");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postBlockComment_setsBlockIdOnComment() {
|
||||||
|
UUID documentId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("felix").firstName("Felix").lastName("Brandt").build();
|
||||||
|
when(commentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
DocumentComment c = inv.getArgument(0);
|
||||||
|
c.setId(UUID.randomUUID());
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postBlockComment(
|
||||||
|
documentId, blockId, "Looks like Breslau", List.of(), author);
|
||||||
|
|
||||||
|
assertThat(result.getBlockId()).isEqualTo(blockId);
|
||||||
|
assertThat(result.getDocumentId()).isEqualTo(documentId);
|
||||||
|
assertThat(result.getContent()).isEqualTo("Looks like Breslau");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CustomUserDetailsServiceTest {
|
||||||
|
|
||||||
|
@Mock AppUserRepository userRepository;
|
||||||
|
@InjectMocks CustomUserDetailsService service;
|
||||||
|
|
||||||
|
// ─── loadUserByUsername — not found ──────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadUserByUsername_throwsUsernameNotFoundException_whenUserNotFound() {
|
||||||
|
when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.loadUserByUsername("ghost"))
|
||||||
|
.isInstanceOf(UsernameNotFoundException.class)
|
||||||
|
.hasMessageContaining("ghost");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── loadUserByUsername — happy path ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadUserByUsername_returnsUserDetails_withMappedAuthorities() {
|
||||||
|
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins")
|
||||||
|
.permissions(Set.of("READ_ALL", "WRITE_ALL")).build();
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
||||||
|
.username("admin").password("hashed").enabled(true)
|
||||||
|
.groups(Set.of(group)).build();
|
||||||
|
when(userRepository.findByUsername("admin")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
UserDetails details = service.loadUserByUsername("admin");
|
||||||
|
|
||||||
|
assertThat(details.getUsername()).isEqualTo("admin");
|
||||||
|
assertThat(details.getAuthorities()).extracting("authority")
|
||||||
|
.contains("READ_ALL", "WRITE_ALL");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadUserByUsername_returnsEmptyAuthorities_whenUserHasNoGroups() {
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
||||||
|
.username("viewer").password("hashed").enabled(true)
|
||||||
|
.groups(Set.of()).build();
|
||||||
|
when(userRepository.findByUsername("viewer")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
UserDetails details = service.loadUserByUsername("viewer");
|
||||||
|
|
||||||
|
assertThat(details.getAuthorities()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── loadUserByUsername — unknown permission ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadUserByUsername_grantsUnknownPermission_butLogsWarning() {
|
||||||
|
// Unknown permissions should still be granted (logged as warning, not silently dropped)
|
||||||
|
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("CustomGroup")
|
||||||
|
.permissions(Set.of("UNKNOWN_CUSTOM_PERM")).build();
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
||||||
|
.username("custom").password("hashed").enabled(true)
|
||||||
|
.groups(Set.of(group)).build();
|
||||||
|
when(userRepository.findByUsername("custom")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
UserDetails details = service.loadUserByUsername("custom");
|
||||||
|
|
||||||
|
assertThat(details.getAuthorities()).extracting("authority")
|
||||||
|
.contains("UNKNOWN_CUSTOM_PERM");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── loadUserByUsername — disabled user ───────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadUserByUsername_returnsDisabledUser_whenUserIsDisabled() {
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
||||||
|
.username("disabled").password("hashed").enabled(false)
|
||||||
|
.groups(Set.of()).build();
|
||||||
|
when(userRepository.findByUsername("disabled")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
UserDetails details = service.loadUserByUsername("disabled");
|
||||||
|
|
||||||
|
assertThat(details.isEnabled()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── loadUserByUsername — multi-group permission merge ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loadUserByUsername_mergesPermissionsFromMultipleGroups() {
|
||||||
|
UserGroup g1 = UserGroup.builder().id(UUID.randomUUID()).name("Readers")
|
||||||
|
.permissions(Set.of("READ_ALL")).build();
|
||||||
|
UserGroup g2 = UserGroup.builder().id(UUID.randomUUID()).name("Writers")
|
||||||
|
.permissions(Set.of("WRITE_ALL")).build();
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID())
|
||||||
|
.username("multi").password("hashed").enabled(true)
|
||||||
|
.groups(Set.of(g1, g2)).build();
|
||||||
|
when(userRepository.findByUsername("multi")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
UserDetails details = service.loadUserByUsername("multi");
|
||||||
|
|
||||||
|
assertThat(details.getAuthorities()).extracting("authority")
|
||||||
|
.containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,16 @@ import org.mockito.InjectMocks;
|
|||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentStatus;
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.Tag;
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
@@ -360,12 +364,30 @@ class DocumentServiceTest {
|
|||||||
// ─── findIncompleteDocuments ──────────────────────────────────────────────
|
// ─── findIncompleteDocuments ──────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findIncompleteDocuments_returnsDocumentsOrderedByCreatedAtDesc() {
|
void findIncompleteDocuments_returnsDTOsWithIdAndTitle() {
|
||||||
Document doc = Document.builder().id(UUID.randomUUID()).title("Test").build();
|
UUID id = UUID.randomUUID();
|
||||||
when(documentRepository.findByMetadataCompleteFalse(any(Sort.class))).thenReturn(List.of(doc));
|
Document doc = Document.builder().id(id).title("Unvollständig").build();
|
||||||
|
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(doc)));
|
||||||
|
|
||||||
assertThat(documentService.findIncompleteDocuments()).containsExactly(doc);
|
List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);
|
||||||
verify(documentRepository).findByMetadataCompleteFalse(Sort.by(Sort.Direction.DESC, "createdAt"));
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).id()).isEqualTo(id);
|
||||||
|
assertThat(result.get(0).title()).isEqualTo("Unvollständig");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findIncompleteDocuments_passesSizeToPageable() {
|
||||||
|
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
||||||
|
.thenReturn(Page.empty());
|
||||||
|
|
||||||
|
documentService.findIncompleteDocuments(3);
|
||||||
|
|
||||||
|
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||||
|
verify(documentRepository).findByMetadataCompleteFalse(captor.capture());
|
||||||
|
assertThat(captor.getValue().getPageSize()).isEqualTo(3);
|
||||||
|
assertThat(captor.getValue().getSort()).isEqualTo(Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── findNextIncompleteDocument ───────────────────────────────────────────
|
// ─── findNextIncompleteDocument ───────────────────────────────────────────
|
||||||
@@ -670,4 +692,585 @@ class DocumentServiceTest {
|
|||||||
void titleFromFilename_null_returnsNull() {
|
void titleFromFilename_null_returnsNull() {
|
||||||
assertThat(DocumentService.titleFromFilename(null)).isNull();
|
assertThat(DocumentService.titleFromFilename(null)).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── titleFromFilename — tryParseDate invalid cases ───────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenIsoDateHasInvalidMonth() {
|
||||||
|
// 1965-13-12 → month 13 is invalid → tryParseDate returns null → fallback
|
||||||
|
assertThat(DocumentService.titleFromFilename("1965-13-12_Mueller_Hans.pdf"))
|
||||||
|
.isEqualTo("1965-13-12_Mueller_Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenIsoDateHasInvalidDay() {
|
||||||
|
// 1965-03-00 → day 0 is invalid → tryParseDate returns null → fallback
|
||||||
|
assertThat(DocumentService.titleFromFilename("1965-03-00_Mueller_Hans.pdf"))
|
||||||
|
.isEqualTo("1965-03-00_Mueller_Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenCompactDateHasInvalidMonth() {
|
||||||
|
// 19651312 → month 13 → invalid
|
||||||
|
assertThat(DocumentService.titleFromFilename("19651312_Mueller_Hans.pdf"))
|
||||||
|
.isEqualTo("19651312_Mueller_Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenCompactDateHasInvalidDay() {
|
||||||
|
// 19650300 → day 0 → invalid
|
||||||
|
assertThat(DocumentService.titleFromFilename("19650300_Mueller_Hans.pdf"))
|
||||||
|
.isEqualTo("19650300_Mueller_Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenStemHasNoExtension() {
|
||||||
|
// No dot → parseFilenameData returns null → titleFromFilename returns null? No,
|
||||||
|
// actually it returns null when filename is null, otherwise stripExtension is called.
|
||||||
|
// Without a dot, dot = -1, strip returns the whole string.
|
||||||
|
assertThat(DocumentService.titleFromFilename("Mueller_Hans_19650312"))
|
||||||
|
.isEqualTo("Mueller_Hans_19650312");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenNamePartsContainNonLetters() {
|
||||||
|
// Parts with numbers/hyphens fail the \p{L}+ regex → returns null → fallback
|
||||||
|
assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller_H4ns.pdf"))
|
||||||
|
.isEqualTo("1965-03-12_Mueller_H4ns");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenOnlyTwoParts() {
|
||||||
|
// "1965-03-12_Mueller.pdf" → less than 2 name parts → null → fallback
|
||||||
|
assertThat(DocumentService.titleFromFilename("1965-03-12_Mueller.pdf"))
|
||||||
|
.isEqualTo("1965-03-12_Mueller");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenIsoDateHasMonthZero() {
|
||||||
|
// 1965-00-12 → month 0 → m >= 1 is false → tryParseDate returns null
|
||||||
|
assertThat(DocumentService.titleFromFilename("1965-00-12_Mueller_Hans.pdf"))
|
||||||
|
.isEqualTo("1965-00-12_Mueller_Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenIsoDateHasDayAbove31() {
|
||||||
|
// 1965-03-32 → day 32 > 31 → d <= 31 is false → tryParseDate returns null
|
||||||
|
assertThat(DocumentService.titleFromFilename("1965-03-32_Mueller_Hans.pdf"))
|
||||||
|
.isEqualTo("1965-03-32_Mueller_Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenCompactDateHasMonthZero() {
|
||||||
|
// 19650012 → month 0 → m >= 1 is false
|
||||||
|
assertThat(DocumentService.titleFromFilename("19650012_Mueller_Hans.pdf"))
|
||||||
|
.isEqualTo("19650012_Mueller_Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStrippedName_whenCompactDateHasDayAbove31() {
|
||||||
|
// 19650332 → day 32 > 31
|
||||||
|
assertThat(DocumentService.titleFromFilename("19650332_Mueller_Hans.pdf"))
|
||||||
|
.isEqualTo("19650332_Mueller_Hans");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getConversationFiltered ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getConversationFiltered_passesGivenDates_whenFromAndToAreProvided() {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
UUID receiverId = UUID.randomUUID();
|
||||||
|
LocalDate from = LocalDate.of(1940, 1, 1);
|
||||||
|
LocalDate to = LocalDate.of(1960, 12, 31);
|
||||||
|
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
|
||||||
|
when(documentRepository.findConversation(senderId, receiverId, from, to, sort))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
|
||||||
|
|
||||||
|
verify(documentRepository).findConversation(senderId, receiverId, from, to, sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getConversationFiltered_usesMinDateForFrom_whenFromIsNull() {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
UUID receiverId = UUID.randomUUID();
|
||||||
|
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
|
||||||
|
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
|
||||||
|
|
||||||
|
ArgumentCaptor<LocalDate> fromCaptor = ArgumentCaptor.forClass(LocalDate.class);
|
||||||
|
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), fromCaptor.capture(), any(LocalDate.class), eq(sort));
|
||||||
|
assertThat(fromCaptor.getValue()).isEqualTo(LocalDate.parse("0000-01-01"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getConversationFiltered_usesTodayForTo_whenToIsNull() {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
UUID receiverId = UUID.randomUUID();
|
||||||
|
Sort sort = Sort.by(Sort.Direction.ASC, "documentDate");
|
||||||
|
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), any(LocalDate.class), eq(sort)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
|
||||||
|
|
||||||
|
ArgumentCaptor<LocalDate> toCaptor = ArgumentCaptor.forClass(LocalDate.class);
|
||||||
|
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(LocalDate.class), toCaptor.capture(), eq(sort));
|
||||||
|
assertThat(toCaptor.getValue()).isEqualTo(LocalDate.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updateDocumentTags — empty tag in list ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocumentTags_skipsEmptyTagNames() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T").build();
|
||||||
|
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||||
|
|
||||||
|
// List with empty string element — cleanName.isEmpty() branch hit
|
||||||
|
documentService.updateDocumentTags(id, List.of("Familie", " ", ""));
|
||||||
|
|
||||||
|
verify(tagService).findOrCreate("Familie");
|
||||||
|
verify(tagService, times(1)).findOrCreate(any()); // only "Familie" — others skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createDocument — with empty tag segment ──────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_filtersEmptyTagSegments() throws Exception {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Test");
|
||||||
|
dto.setTags("Familie, ,"); // middle and trailing blank segments
|
||||||
|
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
Document d = inv.getArgument(0);
|
||||||
|
if (d.getId() == null) {
|
||||||
|
return Document.builder().id(docId).title(d.getTitle()).build();
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
when(documentRepository.findById(docId)).thenReturn(Optional.of(
|
||||||
|
Document.builder().id(docId).title("Test").build()));
|
||||||
|
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||||
|
|
||||||
|
documentService.createDocument(dto, null);
|
||||||
|
|
||||||
|
verify(tagService).findOrCreate("Familie");
|
||||||
|
verify(tagService, times(1)).findOrCreate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createDocument — with sender and receivers ───────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_setsSender_whenSenderIdIsProvided() throws Exception {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
Person sender = Person.builder().id(senderId).firstName("Hans").lastName("M").build();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Test");
|
||||||
|
dto.setSenderId(senderId);
|
||||||
|
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
Document d = inv.getArgument(0);
|
||||||
|
if (d.getId() == null) {
|
||||||
|
Document saved = Document.builder().id(docId).title(d.getTitle()).build();
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
when(documentRepository.findById(docId)).thenReturn(Optional.of(
|
||||||
|
Document.builder().id(docId).title("Test").build()));
|
||||||
|
when(personService.getById(senderId)).thenReturn(sender);
|
||||||
|
|
||||||
|
documentService.createDocument(dto, null);
|
||||||
|
|
||||||
|
verify(personService).getById(senderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_setsReceivers_whenReceiverIdsAreProvided() throws Exception {
|
||||||
|
UUID r1Id = UUID.randomUUID();
|
||||||
|
UUID r2Id = UUID.randomUUID();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Person r1 = Person.builder().id(r1Id).firstName("A").lastName("B").build();
|
||||||
|
Person r2 = Person.builder().id(r2Id).firstName("C").lastName("D").build();
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Test");
|
||||||
|
dto.setReceiverIds(List.of(r1Id, r2Id));
|
||||||
|
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
Document d = inv.getArgument(0);
|
||||||
|
if (d.getId() == null) {
|
||||||
|
return Document.builder().id(docId).title(d.getTitle()).build();
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
when(documentRepository.findById(docId)).thenReturn(Optional.of(
|
||||||
|
Document.builder().id(docId).title("Test").build()));
|
||||||
|
when(personService.getAllById(List.of(r1Id, r2Id))).thenReturn(List.of(r1, r2));
|
||||||
|
|
||||||
|
documentService.createDocument(dto, null);
|
||||||
|
|
||||||
|
verify(personService).getAllById(List.of(r1Id, r2Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createDocument — empty file fallback and blank tags ─────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_usesUnbenanntesDocument_whenFileIsEmptyAndTitleIsNull() throws Exception {
|
||||||
|
// file != null but isEmpty() = true → falls through to title ternary
|
||||||
|
// title == null → "Unbenanntes Dokument"
|
||||||
|
MockMultipartFile emptyFile = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[0]);
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO(); // title = null
|
||||||
|
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).title("Unbenanntes Dokument")
|
||||||
|
.originalFilename("Unbenanntes Dokument").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||||
|
|
||||||
|
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||||
|
documentService.createDocument(dto, emptyFile);
|
||||||
|
|
||||||
|
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||||
|
assertThat(captor.getAllValues().get(0).getOriginalFilename()).isEqualTo("Unbenanntes Dokument");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_skipsTagProcessing_whenTagsIsBlank() throws Exception {
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Doc");
|
||||||
|
dto.setTags(" "); // not null but blank → condition false
|
||||||
|
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||||
|
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||||
|
|
||||||
|
documentService.createDocument(dto, null);
|
||||||
|
|
||||||
|
verify(tagService, never()).findOrCreate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_setsMetadataCompleteFalse_whenReceiverIdsIsEmptyList() throws Exception {
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Doc");
|
||||||
|
dto.setReceiverIds(List.of()); // not null but empty → !isEmpty() = false → false
|
||||||
|
|
||||||
|
Document saved = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||||
|
.originalFilename("Doc").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.save(any())).thenReturn(saved);
|
||||||
|
when(documentRepository.findById(any())).thenReturn(Optional.of(saved));
|
||||||
|
|
||||||
|
ArgumentCaptor<Document> captor = ArgumentCaptor.forClass(Document.class);
|
||||||
|
documentService.createDocument(dto, null);
|
||||||
|
|
||||||
|
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||||
|
assertThat(captor.getAllValues().get(0).isMetadataComplete()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updateDocument — empty file, blank tags, empty receivers ────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_skipsTagProcessing_whenTagsIsBlank() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("T");
|
||||||
|
dto.setTags(" "); // not null but blank
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null);
|
||||||
|
|
||||||
|
verify(tagService, never()).findOrCreate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_clearsReceivers_whenReceiverIdsIsEmptyList() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person r1 = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>(Set.of(r1))).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("T");
|
||||||
|
dto.setReceiverIds(List.of()); // not null but empty → else → clear
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null);
|
||||||
|
|
||||||
|
assertThat(doc.getReceivers()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_skipsFileUpload_whenNewFileIsEmpty() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||||
|
MockMultipartFile emptyFile = new MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[0]);
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("T");
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, emptyFile);
|
||||||
|
|
||||||
|
verify(fileService, never()).uploadFile(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── titleFromFilename — no date in any position ──────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void titleFromFilename_returnsStripped_whenNeitherFirstNorLastPartIsDate() {
|
||||||
|
// "Mueller_Hans_Schmitt.pdf" → 3 parts, none is a date → dateFromLast == null → null → stripExtension
|
||||||
|
assertThat(DocumentService.titleFromFilename("Mueller_Hans_Schmitt.pdf"))
|
||||||
|
.isEqualTo("Mueller_Hans_Schmitt");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsTags_withEmptySegmentsFiltered() throws Exception {
|
||||||
|
// Tags string with blank segment: "Familie, ,Reise" → only "Familie" and "Reise" used
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Tag t1 = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||||
|
Tag t2 = Tag.builder().id(UUID.randomUUID()).name("Reise").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||||
|
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(tagService.findOrCreate("Familie")).thenReturn(t1);
|
||||||
|
when(tagService.findOrCreate("Reise")).thenReturn(t2);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("T");
|
||||||
|
dto.setTags("Familie, ,Reise"); // blank middle segment filtered
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null);
|
||||||
|
|
||||||
|
verify(tagService).findOrCreate("Familie");
|
||||||
|
verify(tagService).findOrCreate("Reise");
|
||||||
|
verify(tagService, times(2)).findOrCreate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_setsTags_whenTagsStringIsProvided() throws Exception {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Test");
|
||||||
|
dto.setTags("Familie");
|
||||||
|
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
Document d = inv.getArgument(0);
|
||||||
|
if (d.getId() == null) {
|
||||||
|
return Document.builder().id(docId).title(d.getTitle()).build();
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
when(documentRepository.findById(docId)).thenReturn(Optional.of(
|
||||||
|
Document.builder().id(docId).title("Test").build()));
|
||||||
|
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||||
|
|
||||||
|
documentService.createDocument(dto, null);
|
||||||
|
|
||||||
|
verify(tagService).findOrCreate("Familie");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updateDocument — with sender / clear receivers ──────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_clearsSender_whenSenderIdIsNull() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person existingSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T").sender(existingSender).receivers(new HashSet<>()).build();
|
||||||
|
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc)); // also for updateDocumentTags
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("T");
|
||||||
|
// senderId is null — should clear sender
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null);
|
||||||
|
|
||||||
|
verify(documentRepository, atLeastOnce()).save(argThat(d -> d.getSender() == null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsReceivers_whenReceiverIdsAreProvided() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UUID r1Id = UUID.randomUUID();
|
||||||
|
Person r1 = Person.builder().id(r1Id).firstName("A").lastName("B").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||||
|
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.getAllById(List.of(r1Id))).thenReturn(List.of(r1));
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("T");
|
||||||
|
dto.setReceiverIds(List.of(r1Id));
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null);
|
||||||
|
|
||||||
|
verify(personService).getAllById(List.of(r1Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsTags_whenTagsStringIsProvided() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Reise").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||||
|
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(tagService.findOrCreate("Reise")).thenReturn(tag);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("T");
|
||||||
|
dto.setTags("Reise");
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null);
|
||||||
|
|
||||||
|
verify(tagService).findOrCreate("Reise");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsSender_whenSenderIdIsProvided() throws Exception {
|
||||||
|
// dto.getSenderId() != null → true branch: sets sender via personService
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
Person sender = Person.builder().id(senderId).firstName("Hans").lastName("M").build();
|
||||||
|
Document doc = Document.builder().id(id).title("T").receivers(new HashSet<>()).build();
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(doc));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.getById(senderId)).thenReturn(sender);
|
||||||
|
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("T");
|
||||||
|
dto.setSenderId(senderId);
|
||||||
|
|
||||||
|
documentService.updateDocument(id, dto, null);
|
||||||
|
|
||||||
|
verify(personService).getById(senderId);
|
||||||
|
assertThat(doc.getSender()).isEqualTo(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── stripExtension / parseFilenameData — null guard branches ────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void stripExtension_returnsNull_whenFilenameIsNull() throws Exception {
|
||||||
|
// filename == null = true → null guard branch in private static method
|
||||||
|
java.lang.reflect.Method method = DocumentService.class
|
||||||
|
.getDeclaredMethod("stripExtension", String.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
String result = (String) method.invoke(null, (String) null);
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseFilenameData_returnsNull_whenFilenameIsNull() throws Exception {
|
||||||
|
// filename == null = true → null guard branch in private static method
|
||||||
|
java.lang.reflect.Method method = DocumentService.class
|
||||||
|
.getDeclaredMethod("parseFilenameData", String.class);
|
||||||
|
method.setAccessible(true);
|
||||||
|
Object result = method.invoke(null, (String) null);
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── searchDocuments — status filter ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_passesStatusSpecificationToRepository() {
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
documentService.searchDocuments(null, null, null, null, null, null, DocumentStatus.REVIEWED);
|
||||||
|
|
||||||
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void searchDocuments_withNullStatus_doesNotFilterByStatus() {
|
||||||
|
when(documentRepository.findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
documentService.searchDocuments(null, null, null, null, null, null, null);
|
||||||
|
|
||||||
|
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getRecentActivity ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRecentActivity_returnsMostRecentlyUpdatedDocuments() {
|
||||||
|
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Oldest").build();
|
||||||
|
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Middle").build();
|
||||||
|
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Newest").build();
|
||||||
|
|
||||||
|
Page<Document> page = new PageImpl<>(List.of(doc3, doc2));
|
||||||
|
when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);
|
||||||
|
|
||||||
|
List<Document> result = documentService.getRecentActivity(2);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertThat(result).containsExactly(doc3, doc2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getRecentActivity_usesPageRequestWithSizeLimit_notFindAll() {
|
||||||
|
Page<Document> page = new PageImpl<>(List.of());
|
||||||
|
when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);
|
||||||
|
|
||||||
|
documentService.getRecentActivity(3);
|
||||||
|
|
||||||
|
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||||
|
verify(documentRepository).findAll(captor.capture());
|
||||||
|
assertThat(captor.getValue().getPageSize()).isEqualTo(3);
|
||||||
|
assertThat(captor.getValue().getSort())
|
||||||
|
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getConversationFiltered (single-person mode) ─────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getConversationFiltered_callsSinglePersonQuery_whenReceiverIdIsNull() {
|
||||||
|
UUID personId = UUID.randomUUID();
|
||||||
|
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||||
|
when(documentRepository.findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
documentService.getConversationFiltered(personId, null, null, null, sort);
|
||||||
|
|
||||||
|
verify(documentRepository).findSinglePersonCorrespondence(eq(personId), any(), any(), eq(sort));
|
||||||
|
verify(documentRepository, never()).findConversation(any(), any(), any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getConversationFiltered_callsBilateralQuery_whenReceiverIdIsSet() {
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
UUID receiverId = UUID.randomUUID();
|
||||||
|
Sort sort = Sort.by(Sort.Direction.DESC, "documentDate");
|
||||||
|
when(documentRepository.findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
documentService.getConversationFiltered(senderId, receiverId, null, null, sort);
|
||||||
|
|
||||||
|
verify(documentRepository).findConversation(eq(senderId), eq(receiverId), any(), any(), eq(sort));
|
||||||
|
verify(documentRepository, never()).findSinglePersonCorrespondence(any(), any(), any(), any());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,6 +374,366 @@ class DocumentVersionServiceTest {
|
|||||||
assertThat(count).isEqualTo(2);
|
assertThat(count).isEqualTo(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── recordVersion — no auth / user not found ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_usesUnknown_whenSecurityContextHasNoAuthentication() {
|
||||||
|
// No call to authenticateAs — context is cleared
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.recordVersion(minimalDocument());
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getEditorName()).isEqualTo("Unknown");
|
||||||
|
assertThat(captor.getValue().getEditorId()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_usesUnknown_whenAuthenticationIsNotAuthenticated() {
|
||||||
|
// Auth present but isAuthenticated() = false — use TestingAuthenticationToken
|
||||||
|
org.springframework.security.authentication.TestingAuthenticationToken notAuth =
|
||||||
|
new org.springframework.security.authentication.TestingAuthenticationToken("user", null);
|
||||||
|
notAuth.setAuthenticated(false);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(notAuth);
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.recordVersion(minimalDocument());
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getEditorName()).isEqualTo("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_usesUnknown_whenUserServiceThrows() {
|
||||||
|
authenticateAs("missinguser");
|
||||||
|
when(userService.findByUsername("missinguser")).thenThrow(new RuntimeException("not found"));
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.recordVersion(minimalDocument());
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getEditorName()).isEqualTo("Unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── recordVersion — buildEditorName edge cases ───────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_usesUsername_whenFirstNameIsNotBlankButLastNameIsNull() {
|
||||||
|
authenticateAs("user42");
|
||||||
|
when(userService.findByUsername("user42")).thenReturn(
|
||||||
|
AppUser.builder().id(UUID.randomUUID()).username("user42")
|
||||||
|
.firstName("Hans").lastName(null).build());
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.recordVersion(minimalDocument());
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_usesUsername_whenFirstNameIsBlankButLastNameIsPresent() {
|
||||||
|
authenticateAs("user42");
|
||||||
|
when(userService.findByUsername("user42")).thenReturn(
|
||||||
|
AppUser.builder().id(UUID.randomUUID()).username("user42")
|
||||||
|
.firstName(" ").lastName("Müller").build());
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.recordVersion(minimalDocument());
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_usesUsername_whenLastNameIsBlankButFirstNameIsPresent() {
|
||||||
|
authenticateAs("user42");
|
||||||
|
when(userService.findByUsername("user42")).thenReturn(
|
||||||
|
AppUser.builder().id(UUID.randomUUID()).username("user42")
|
||||||
|
.firstName("Hans").lastName(" ").build());
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(any())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.recordVersion(minimalDocument());
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getEditorName()).isEqualTo("user42");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── recordVersion — computeChangedFields with corrupt snapshot ──────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_returnsEmptyChangedFields_whenPreviousSnapshotIsInvalidJson() {
|
||||||
|
authenticateAs("user1");
|
||||||
|
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||||
|
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
DocumentVersion previous = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).snapshot("INVALID JSON")
|
||||||
|
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||||
|
.editorName("user1").build();
|
||||||
|
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.recordVersion(Document.builder().id(docId).title("T").build());
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).isEqualTo("[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── recordVersion — checkSender/checkReceivers/checkTags with no previous ─
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_tracksSenderAdded_whenPreviousHadNoSender() throws Exception {
|
||||||
|
authenticateAs("user1");
|
||||||
|
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document oldDoc = Document.builder().id(docId).title("T").build(); // no sender
|
||||||
|
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||||
|
|
||||||
|
DocumentVersion previous = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||||
|
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||||
|
.editorName("user1").build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Person newSender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
||||||
|
Document updated = Document.builder().id(docId).title("T").sender(newSender).build();
|
||||||
|
versionService.recordVersion(updated);
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).contains("sender");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_tracksReceiversAdded_whenPreviousHadNone() throws Exception {
|
||||||
|
authenticateAs("user1");
|
||||||
|
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document oldDoc = Document.builder().id(docId).title("T").build(); // no receivers
|
||||||
|
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||||
|
|
||||||
|
DocumentVersion previous = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||||
|
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||||
|
.editorName("user1").build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Person r = Person.builder().id(UUID.randomUUID()).firstName("C").lastName("D").build();
|
||||||
|
Document updated = Document.builder().id(docId).title("T").receivers(Set.of(r)).build();
|
||||||
|
versionService.recordVersion(updated);
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).contains("receivers");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_tracksTagsAdded_whenPreviousHadNone() throws Exception {
|
||||||
|
authenticateAs("user1");
|
||||||
|
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document oldDoc = Document.builder().id(docId).title("T").build(); // no tags
|
||||||
|
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||||
|
|
||||||
|
DocumentVersion previous = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||||
|
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||||
|
.editorName("user1").build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||||
|
Document updated = Document.builder().id(docId).title("T").tags(Set.of(tag)).build();
|
||||||
|
versionService.recordVersion(updated);
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).contains("tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── checkSender — sender map with null id ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_senderChangedToPresent_whenPreviousSenderHasNullId() throws Exception {
|
||||||
|
// Covers: prevSender instanceof Map = true, but id == null → prevId = null
|
||||||
|
authenticateAs("user1");
|
||||||
|
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||||
|
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
// Manually craft a JSON where sender object exists but id is null
|
||||||
|
String oldSnapshot = "{\"id\":\"" + docId + "\",\"title\":\"T\","
|
||||||
|
+ "\"sender\":{\"id\":null,\"firstName\":\"A\",\"lastName\":\"B\"},"
|
||||||
|
+ "\"receivers\":[],\"tags\":[]}";
|
||||||
|
|
||||||
|
DocumentVersion previous = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||||
|
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||||
|
.editorName("user1").build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Person newSender = Person.builder().id(UUID.randomUUID()).firstName("B").lastName("C").build();
|
||||||
|
Document updated = Document.builder().id(docId).title("T").sender(newSender).build();
|
||||||
|
versionService.recordVersion(updated);
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).contains("sender");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── checkSender — sender unchanged → not in changedFields ───────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_doesNotTrackSender_whenSenderUnchanged() throws Exception {
|
||||||
|
// Covers: !Objects.equals(currentId, prevId) = false → don't add "sender"
|
||||||
|
authenticateAs("user1");
|
||||||
|
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID senderId = UUID.randomUUID();
|
||||||
|
Person sender = Person.builder().id(senderId).firstName("A").lastName("B").build();
|
||||||
|
Document oldDoc = Document.builder().id(docId).title("T").sender(sender).build();
|
||||||
|
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||||
|
|
||||||
|
DocumentVersion previous = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||||
|
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||||
|
.editorName("user1").build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
// Same sender — should NOT be in changedFields
|
||||||
|
Document updated = Document.builder().id(docId).title("T").sender(sender).build();
|
||||||
|
versionService.recordVersion(updated);
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).doesNotContain("sender");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── computeChangedFields — documentDate ternary true branch ─────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_tracksDocumentDate_whenCurrentDocHasNonNullDate() throws Exception {
|
||||||
|
// current.getDocumentDate() != null = true → ternary true branch in computeChangedFields
|
||||||
|
authenticateAs("user1");
|
||||||
|
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document oldDoc = Document.builder().id(docId).title("T").build(); // no date in previous
|
||||||
|
String oldSnapshot = mapper.writeValueAsString(oldDoc);
|
||||||
|
|
||||||
|
DocumentVersion previous = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||||
|
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||||
|
.editorName("user1").build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
// Current doc has a non-null documentDate → ternary evaluates its true branch
|
||||||
|
Document updated = Document.builder().id(docId).title("T")
|
||||||
|
.documentDate(LocalDate.of(1965, 3, 12)).build();
|
||||||
|
versionService.recordVersion(updated);
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).contains("documentDate");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── checkReceivers / checkTags — when previous snapshot has null values ───
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_tracksReceivers_whenPreviousSnapshotHasNullReceivers() throws Exception {
|
||||||
|
// prevReceivers NOT instanceof List<?> → prevIds = Set.of() → if currentIds differ → added
|
||||||
|
authenticateAs("user1");
|
||||||
|
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||||
|
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
// Craft snapshot where "receivers" is JSON null → deserialized as null, NOT a List
|
||||||
|
String oldSnapshot = "{\"id\":\"" + docId + "\",\"title\":\"T\",\"receivers\":null,\"tags\":[]}";
|
||||||
|
|
||||||
|
DocumentVersion previous = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||||
|
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||||
|
.editorName("user1").build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Person r = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
||||||
|
Document updated = Document.builder().id(docId).title("T").receivers(Set.of(r)).build();
|
||||||
|
versionService.recordVersion(updated);
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).contains("receivers");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recordVersion_tracksTags_whenPreviousSnapshotHasNullTags() throws Exception {
|
||||||
|
// prevTags NOT instanceof List<?> → prevNames = Set.of() → if currentNames differ → added
|
||||||
|
authenticateAs("user1");
|
||||||
|
when(userService.findByUsername("user1")).thenReturn(stubUser("user1"));
|
||||||
|
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
// Craft snapshot where "tags" is JSON null → deserialized as null, NOT a List
|
||||||
|
String oldSnapshot = "{\"id\":\"" + docId + "\",\"title\":\"T\",\"receivers\":[],\"tags\":null}";
|
||||||
|
|
||||||
|
DocumentVersion previous = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).snapshot(oldSnapshot)
|
||||||
|
.changedFields("[]").savedAt(LocalDateTime.now().minusMinutes(5))
|
||||||
|
.editorName("user1").build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(docId)).thenReturn(List.of(previous));
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||||
|
Document updated = Document.builder().id(docId).title("T").tags(Set.of(tag)).build();
|
||||||
|
versionService.recordVersion(updated);
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getChangedFields()).contains("tags");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── backfill — uses LocalDateTime.now() when createdAt is null ──────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_usesNow_whenDocumentCreatedAtIsNull() {
|
||||||
|
Document doc = Document.builder().id(UUID.randomUUID()).title("T").createdAt(null).build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
versionService.backfillMissingVersions(List.of(doc));
|
||||||
|
|
||||||
|
ArgumentCaptor<DocumentVersion> captor = ArgumentCaptor.forClass(DocumentVersion.class);
|
||||||
|
verify(versionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getSavedAt()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void authenticateAs(String username) {
|
private void authenticateAs(String username) {
|
||||||
|
|||||||
@@ -4,15 +4,23 @@ import org.junit.jupiter.api.BeforeEach;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.springframework.mock.web.MockMultipartFile;
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import software.amazon.awssdk.core.ResponseInputStream;
|
||||||
import software.amazon.awssdk.core.sync.RequestBody;
|
import software.amazon.awssdk.core.sync.RequestBody;
|
||||||
|
import software.amazon.awssdk.http.AbortableInputStream;
|
||||||
import software.amazon.awssdk.services.s3.S3Client;
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.GetObjectResponse;
|
||||||
|
import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
|
||||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||||
|
import software.amazon.awssdk.services.s3.model.S3Exception;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@@ -82,4 +90,111 @@ class FileServiceTest {
|
|||||||
|
|
||||||
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
|
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadFile_throwsIOException_whenS3Throws() {
|
||||||
|
MockMultipartFile file = new MockMultipartFile("f", "fail.pdf", "application/pdf", new byte[]{1});
|
||||||
|
S3Exception s3ex = (S3Exception) S3Exception.builder().message("bucket error").statusCode(500).build();
|
||||||
|
when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))).thenThrow(s3ex);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> fileService.uploadFile(file, "fail.pdf"))
|
||||||
|
.isInstanceOf(IOException.class)
|
||||||
|
.hasMessageContaining("Failed to upload");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── downloadFile ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadFile_returnsResourceWithContentType() {
|
||||||
|
byte[] content = "pdf content".getBytes();
|
||||||
|
GetObjectResponse response = GetObjectResponse.builder().contentType("application/pdf").build();
|
||||||
|
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
|
||||||
|
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
|
||||||
|
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
|
||||||
|
|
||||||
|
FileService.S3FileDownload result = fileService.downloadFile("documents/test.pdf");
|
||||||
|
|
||||||
|
assertThat(result.contentType()).isEqualTo("application/pdf");
|
||||||
|
assertThat(result.resource()).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadFile_fallsBackToOctetStream_whenContentTypeIsBlank() {
|
||||||
|
byte[] content = "data".getBytes();
|
||||||
|
GetObjectResponse response = GetObjectResponse.builder().contentType(" ").build();
|
||||||
|
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
|
||||||
|
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
|
||||||
|
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
|
||||||
|
|
||||||
|
FileService.S3FileDownload result = fileService.downloadFile("documents/file");
|
||||||
|
|
||||||
|
assertThat(result.contentType()).isEqualTo("application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadFile_fallsBackToOctetStream_whenContentTypeIsNull() {
|
||||||
|
byte[] content = "data".getBytes();
|
||||||
|
GetObjectResponse response = GetObjectResponse.builder().build(); // no contentType
|
||||||
|
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
|
||||||
|
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
|
||||||
|
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
|
||||||
|
|
||||||
|
FileService.S3FileDownload result = fileService.downloadFile("documents/file");
|
||||||
|
|
||||||
|
assertThat(result.contentType()).isEqualTo("application/octet-stream");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadFile_throwsStorageFileNotFoundException_whenNoSuchKey() {
|
||||||
|
NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
|
||||||
|
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> fileService.downloadFile("missing/key.pdf"))
|
||||||
|
.isInstanceOf(FileService.StorageFileNotFoundException.class)
|
||||||
|
.hasMessageContaining("missing/key.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadFile_throwsRuntimeException_whenS3Exception() {
|
||||||
|
S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
|
||||||
|
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> fileService.downloadFile("documents/file.pdf"))
|
||||||
|
.isInstanceOf(RuntimeException.class)
|
||||||
|
.hasMessageContaining("Storage Error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── downloadFileBytes ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadFileBytes_returnsRawBytes() throws IOException {
|
||||||
|
byte[] content = "raw bytes".getBytes();
|
||||||
|
GetObjectResponse response = GetObjectResponse.builder().build();
|
||||||
|
ResponseInputStream<GetObjectResponse> stream = new ResponseInputStream<>(
|
||||||
|
response, AbortableInputStream.create(new ByteArrayInputStream(content)));
|
||||||
|
when(s3Client.getObject(any(GetObjectRequest.class))).thenReturn(stream);
|
||||||
|
|
||||||
|
byte[] result = fileService.downloadFileBytes("documents/file.pdf");
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadFileBytes_throwsStorageFileNotFoundException_whenNoSuchKey() {
|
||||||
|
NoSuchKeyException ex = NoSuchKeyException.builder().message("not found").statusCode(404).build();
|
||||||
|
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> fileService.downloadFileBytes("missing/key.pdf"))
|
||||||
|
.isInstanceOf(FileService.StorageFileNotFoundException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void downloadFileBytes_throwsIOException_whenS3Exception() {
|
||||||
|
S3Exception ex = (S3Exception) S3Exception.builder().message("storage error").statusCode(503).build();
|
||||||
|
when(s3Client.getObject(any(GetObjectRequest.class))).thenThrow(ex);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> fileService.downloadFileBytes("documents/file.pdf"))
|
||||||
|
.isInstanceOf(IOException.class)
|
||||||
|
.hasMessageContaining("Failed to download");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentStatus;
|
||||||
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.model.Tag;
|
||||||
|
import org.raddatz.familienarchiv.repository.DocumentRepository;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import software.amazon.awssdk.core.sync.RequestBody;
|
||||||
|
import software.amazon.awssdk.services.s3.S3Client;
|
||||||
|
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class MassImportServiceTest {
|
||||||
|
|
||||||
|
@Mock DocumentRepository documentRepository;
|
||||||
|
@Mock PersonService personService;
|
||||||
|
@Mock TagService tagService;
|
||||||
|
@Mock S3Client s3Client;
|
||||||
|
|
||||||
|
MassImportService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new MassImportService(documentRepository, personService, tagService, s3Client);
|
||||||
|
ReflectionTestUtils.setField(service, "bucketName", "test-bucket");
|
||||||
|
ReflectionTestUtils.setField(service, "colIndex", 0);
|
||||||
|
ReflectionTestUtils.setField(service, "colBox", 1);
|
||||||
|
ReflectionTestUtils.setField(service, "colFolder", 2);
|
||||||
|
ReflectionTestUtils.setField(service, "colSender", 3);
|
||||||
|
ReflectionTestUtils.setField(service, "colReceivers", 5);
|
||||||
|
ReflectionTestUtils.setField(service, "colDate", 7);
|
||||||
|
ReflectionTestUtils.setField(service, "colLocation", 9);
|
||||||
|
ReflectionTestUtils.setField(service, "colTags", 10);
|
||||||
|
ReflectionTestUtils.setField(service, "colSummary", 11);
|
||||||
|
ReflectionTestUtils.setField(service, "colTranscription", 13);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getStatus ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getStatus_returnsIdleByDefault() {
|
||||||
|
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.IDLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── runImportAsync ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_setsFailedStatus_whenImportDirectoryDoesNotExist() {
|
||||||
|
// /import directory doesn't exist in test environment → findSpreadsheetFile throws
|
||||||
|
service.runImportAsync();
|
||||||
|
|
||||||
|
assertThat(service.getStatus().state()).isEqualTo(MassImportService.State.FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runImportAsync_throwsConflict_whenAlreadyRunning() {
|
||||||
|
MassImportService.ImportStatus running = new MassImportService.ImportStatus(
|
||||||
|
MassImportService.State.RUNNING, "Running...", 0, LocalDateTime.now());
|
||||||
|
ReflectionTestUtils.setField(service, "currentStatus", running);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.runImportAsync())
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("already in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — skip already uploaded ─────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_skips_whenDocumentAlreadyUploadedNotPlaceholder() {
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.originalFilename("doc001.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED)
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.of(existing));
|
||||||
|
|
||||||
|
service.importSingleDocument(minimalCells("doc001.pdf"), Optional.empty(), "doc001.pdf", "doc001");
|
||||||
|
|
||||||
|
verify(documentRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — create new document (metadata only) ───────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_createsNewDocument_whenNotExists() {
|
||||||
|
when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
service.importSingleDocument(minimalCells("doc002.pdf"), Optional.empty(), "doc002.pdf", "doc002");
|
||||||
|
|
||||||
|
verify(documentRepository).save(argThat(d ->
|
||||||
|
d.getOriginalFilename().equals("doc002.pdf")
|
||||||
|
&& d.getStatus() == DocumentStatus.PLACEHOLDER));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — update existing placeholder ──────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_updatesExistingPlaceholder() {
|
||||||
|
Document placeholder = Document.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.originalFilename("existing.pdf")
|
||||||
|
.status(DocumentStatus.PLACEHOLDER)
|
||||||
|
.build();
|
||||||
|
when(documentRepository.findByOriginalFilename("existing.pdf")).thenReturn(Optional.of(placeholder));
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
service.importSingleDocument(minimalCells("existing.pdf"), Optional.empty(), "existing.pdf", "existing");
|
||||||
|
|
||||||
|
verify(documentRepository).save(same(placeholder));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — with file (S3 upload) ─────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_uploadsFileToS3_andSetsStatusUploaded(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path tempFile = tempDir.resolve("doc003.pdf");
|
||||||
|
Files.write(tempFile, "PDF content".getBytes());
|
||||||
|
|
||||||
|
when(documentRepository.findByOriginalFilename("doc003.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
service.importSingleDocument(
|
||||||
|
minimalCells("doc003.pdf"), Optional.of(tempFile.toFile()), "doc003.pdf", "doc003");
|
||||||
|
|
||||||
|
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
verify(documentRepository).save(argThat(d -> d.getStatus() == DocumentStatus.UPLOADED));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_returnsEarly_whenS3UploadFails(@TempDir Path tempDir) throws Exception {
|
||||||
|
Path tempFile = tempDir.resolve("fail.pdf");
|
||||||
|
Files.write(tempFile, "data".getBytes());
|
||||||
|
|
||||||
|
when(documentRepository.findByOriginalFilename("fail.pdf")).thenReturn(Optional.empty());
|
||||||
|
doThrow(new RuntimeException("S3 error"))
|
||||||
|
.when(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
|
||||||
|
service.importSingleDocument(
|
||||||
|
minimalCells("fail.pdf"), Optional.of(tempFile.toFile()), "fail.pdf", "fail");
|
||||||
|
|
||||||
|
verify(documentRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — sender handling ───────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_setsNullSender_whenSenderCellIsBlank() {
|
||||||
|
when(documentRepository.findByOriginalFilename("nosender.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<String> cells = buildCells("nosender.pdf", "", "", "");
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "nosender.pdf", "nosender");
|
||||||
|
|
||||||
|
verify(documentRepository).save(argThat(d -> d.getSender() == null));
|
||||||
|
verify(personService, never()).findOrCreateByAlias(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_createsSender_whenSenderCellIsNonBlank() {
|
||||||
|
Person sender = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
|
||||||
|
when(documentRepository.findByOriginalFilename("withsender.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(sender);
|
||||||
|
|
||||||
|
List<String> cells = buildCells("withsender.pdf", "Walter Müller", "", "");
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "withsender.pdf", "withsender");
|
||||||
|
|
||||||
|
verify(personService).findOrCreateByAlias("Walter Müller");
|
||||||
|
verify(documentRepository).save(argThat(d -> d.getSender() == sender));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — tag handling ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_createsTag_whenTagCellIsNonBlank() {
|
||||||
|
Tag tag = Tag.builder().id(UUID.randomUUID()).name("Familie").build();
|
||||||
|
when(documentRepository.findByOriginalFilename("tagged.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(tagService.findOrCreate("Familie")).thenReturn(tag);
|
||||||
|
|
||||||
|
List<String> cells = buildCells("tagged.pdf", "", "", "Familie");
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "tagged.pdf", "tagged");
|
||||||
|
|
||||||
|
verify(tagService).findOrCreate("Familie");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_doesNotCreateTag_whenTagCellIsBlank() {
|
||||||
|
when(documentRepository.findByOriginalFilename("notag.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<String> cells = buildCells("notag.pdf", "", "", "");
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "notag.pdf", "notag");
|
||||||
|
|
||||||
|
verify(tagService, never()).findOrCreate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — metadataComplete heuristic ───────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_metadataComplete_whenSenderPresent() {
|
||||||
|
Person sender = Person.builder().id(UUID.randomUUID()).firstName("A").lastName("B").build();
|
||||||
|
when(documentRepository.findByOriginalFilename("meta.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.findOrCreateByAlias("A B")).thenReturn(sender);
|
||||||
|
|
||||||
|
List<String> cells = buildCells("meta.pdf", "A B", "", "");
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "meta.pdf", "meta");
|
||||||
|
|
||||||
|
verify(documentRepository).save(argThat(Document::isMetadataComplete));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_metadataIncomplete_whenNoKeyFieldsPresent() {
|
||||||
|
when(documentRepository.findByOriginalFilename("nometa.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<String> cells = buildCells("nometa.pdf", "", "", "");
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "nometa.pdf", "nometa");
|
||||||
|
|
||||||
|
verify(documentRepository).save(argThat(d -> !d.isMetadataComplete()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — blank fields set to null ─────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_setsBlankFieldsToNull() {
|
||||||
|
when(documentRepository.findByOriginalFilename("blank.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<String> cells = buildCells("blank.pdf", "", "", "");
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "blank.pdf", "blank");
|
||||||
|
|
||||||
|
verify(documentRepository).save(argThat(d ->
|
||||||
|
d.getLocation() == null &&
|
||||||
|
d.getSummary() == null &&
|
||||||
|
d.getTranscription() == null &&
|
||||||
|
d.getArchiveBox() == null &&
|
||||||
|
d.getArchiveFolder() == null));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── processRows — via ReflectionTestUtils ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void processRows_returnsZero_whenOnlyHeaderRow() {
|
||||||
|
List<List<String>> rows = List.of(List.of("header", "col1"));
|
||||||
|
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
assertThat(result).isEqualTo(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void processRows_skipsRowWithBlankIndex() {
|
||||||
|
List<List<String>> rows = List.of(
|
||||||
|
List.of("header"),
|
||||||
|
minimalCells("") // blank index
|
||||||
|
);
|
||||||
|
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
assertThat(result).isEqualTo(0);
|
||||||
|
verify(documentRepository, never()).findByOriginalFilename(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void processRows_addsExtension_whenIndexHasNoDot() {
|
||||||
|
when(documentRepository.findByOriginalFilename("doc001.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<List<String>> rows = List.of(
|
||||||
|
List.of("header"),
|
||||||
|
minimalCells("doc001") // no dot → appends ".pdf"
|
||||||
|
);
|
||||||
|
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(1);
|
||||||
|
verify(documentRepository).findByOriginalFilename("doc001.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void processRows_usesFilenameAsIs_whenIndexHasDot() {
|
||||||
|
when(documentRepository.findByOriginalFilename("doc002.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<List<String>> rows = List.of(
|
||||||
|
List.of("header"),
|
||||||
|
minimalCells("doc002.pdf") // has dot → used as-is
|
||||||
|
);
|
||||||
|
Integer result = ReflectionTestUtils.invokeMethod(service, "processRows", rows);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(1);
|
||||||
|
verify(documentRepository).findByOriginalFilename("doc002.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── importSingleDocument — non-blank optional fields ────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_setsNonNullOptionalFields_whenPresent() {
|
||||||
|
when(documentRepository.findByOriginalFilename("rich.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
// box=1, folder=2, location=9, summary=11, transcription=13
|
||||||
|
List<String> cells = List.of(
|
||||||
|
"rich.pdf", // 0: index
|
||||||
|
"Box A", // 1: box
|
||||||
|
"Folder B", // 2: folder
|
||||||
|
"", // 3: sender
|
||||||
|
"", // 4: unused
|
||||||
|
"", // 5: receivers
|
||||||
|
"", // 6: unused
|
||||||
|
"", // 7: date
|
||||||
|
"", // 8: unused
|
||||||
|
"Hamburg", // 9: location
|
||||||
|
"", // 10: tags
|
||||||
|
"A summary", // 11: summary
|
||||||
|
"", // 12: unused
|
||||||
|
"A transcript" // 13: transcription
|
||||||
|
);
|
||||||
|
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "rich.pdf", "rich");
|
||||||
|
|
||||||
|
verify(documentRepository).save(argThat(d ->
|
||||||
|
"Box A".equals(d.getArchiveBox()) &&
|
||||||
|
"Folder B".equals(d.getArchiveFolder()) &&
|
||||||
|
"Hamburg".equals(d.getLocation()) &&
|
||||||
|
"A summary".equals(d.getSummary()) &&
|
||||||
|
"A transcript".equals(d.getTranscription())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_setsMetadataComplete_whenReceiversArePresent() {
|
||||||
|
Person receiver = Person.builder().id(UUID.randomUUID()).firstName("Walter").lastName("Müller").build();
|
||||||
|
when(documentRepository.findByOriginalFilename("rcv.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
when(personService.findOrCreateByAlias("Walter Müller")).thenReturn(receiver);
|
||||||
|
|
||||||
|
List<String> cells = List.of(
|
||||||
|
"rcv.pdf", "", "", "", "", "Walter Müller", "", "", "", "", "", "", "", "");
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "rcv.pdf", "rcv");
|
||||||
|
|
||||||
|
verify(documentRepository).save(argThat(Document::isMetadataComplete));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void importSingleDocument_setsMetadataComplete_whenDateIsPresent() {
|
||||||
|
when(documentRepository.findByOriginalFilename("dated.pdf")).thenReturn(Optional.empty());
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
List<String> cells = List.of(
|
||||||
|
"dated.pdf", "", "", "", "", "", "", "2024-03-15", "", "", "", "", "", "");
|
||||||
|
service.importSingleDocument(cells, Optional.empty(), "dated.pdf", "dated");
|
||||||
|
|
||||||
|
verify(documentRepository).save(argThat(Document::isMetadataComplete));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── buildTitle — null location ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildTitle_withNullLocation_skipsLocationPart() {
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
||||||
|
"doc005", LocalDate.of(1940, 5, 1), (String) null);
|
||||||
|
assertThat(result).contains("doc005").contains("1940");
|
||||||
|
assertThat(result).doesNotContain("Berlin");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── parseDate — via ReflectionTestUtils ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseDate_returnsNull_whenValueIsNull() {
|
||||||
|
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", (String) null);
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseDate_returnsNull_whenValueIsBlank() {
|
||||||
|
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", " ");
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseDate_returnsDate_whenValidIsoFormat() {
|
||||||
|
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "2024-03-15");
|
||||||
|
assertThat(result).isEqualTo(LocalDate.of(2024, 3, 15));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseDate_returnsNull_whenInvalidDateString() {
|
||||||
|
LocalDate result = ReflectionTestUtils.invokeMethod(service, "parseDate", "15.03.2024");
|
||||||
|
assertThat(result).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── buildTitle — via ReflectionTestUtils ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildTitle_withDateAndLocation() {
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
||||||
|
"doc001", LocalDate.of(1940, 5, 1), "Berlin");
|
||||||
|
assertThat(result).contains("doc001").contains("Berlin").contains("1940");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildTitle_withDateOnly() {
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
||||||
|
"doc002", LocalDate.of(1960, 8, 15), "");
|
||||||
|
assertThat(result).contains("doc002").contains("1960");
|
||||||
|
assertThat(result).doesNotContain("Berlin");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildTitle_withIndexOnly_whenDateAndLocationAreNull() {
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
||||||
|
"doc003", null, "");
|
||||||
|
assertThat(result).isEqualTo("doc003");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void buildTitle_withLocationOnly_whenDateIsNull() {
|
||||||
|
// date=null, location present → date part skipped, location appended
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "buildTitle",
|
||||||
|
"doc004", null, "Berlin");
|
||||||
|
assertThat(result).contains("doc004").contains("Berlin");
|
||||||
|
assertThat(result).doesNotContain("("); // no date part
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getCell — via ReflectionTestUtils ───────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCell_returnsEmptyString_whenColBeyondListSize() {
|
||||||
|
List<String> cells = List.of("a", "b");
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 5);
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCell_returnsEmptyString_whenValueIsNull() {
|
||||||
|
List<String> cells = new ArrayList<>();
|
||||||
|
cells.add(null);
|
||||||
|
cells.add("b");
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCell_returnsTrimmedValue() {
|
||||||
|
List<String> cells = List.of(" hello ", "world");
|
||||||
|
String result = ReflectionTestUtils.invokeMethod(service, "getCell", cells, 0);
|
||||||
|
assertThat(result).isEqualTo("hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a minimal 14-element cell row with the given filename at index 0
|
||||||
|
* and blanks for all optional fields.
|
||||||
|
*/
|
||||||
|
private List<String> minimalCells(String filename) {
|
||||||
|
return buildCells(filename, "", "", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a cell row with sender, receiver, and tag controls.
|
||||||
|
* Layout matches the default column indices set in setUp().
|
||||||
|
*/
|
||||||
|
private List<String> buildCells(String filename, String sender, String receivers, String tag) {
|
||||||
|
// 14 elements: index=0,box=1,folder=2,sender=3,[4],receivers=5,[6],date=7,[8],location=9,tag=10,summary=11,[12],transcription=13
|
||||||
|
return List.of(
|
||||||
|
filename, // 0: index
|
||||||
|
"", // 1: box
|
||||||
|
"", // 2: folder
|
||||||
|
sender, // 3: sender
|
||||||
|
"", // 4: (unused)
|
||||||
|
receivers, // 5: receivers
|
||||||
|
"", // 6: (unused)
|
||||||
|
"", // 7: date
|
||||||
|
"", // 8: (unused)
|
||||||
|
"", // 9: location
|
||||||
|
tag, // 10: tags
|
||||||
|
"", // 11: summary
|
||||||
|
"", // 12: (unused)
|
||||||
|
"" // 13: transcription
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.NotificationDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.*;
|
||||||
|
import org.raddatz.familienarchiv.repository.NotificationRepository;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.mail.MailException;
|
||||||
|
import org.springframework.mail.MailSendException;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class NotificationServiceTest {
|
||||||
|
|
||||||
|
@Mock NotificationRepository notificationRepository;
|
||||||
|
@Mock UserService userService;
|
||||||
|
@Mock DocumentService documentService;
|
||||||
|
@Mock JavaMailSender mailSender;
|
||||||
|
@Mock SseEmitterRegistry sseEmitterRegistry;
|
||||||
|
|
||||||
|
NotificationService notificationService;
|
||||||
|
|
||||||
|
private AppUser userA;
|
||||||
|
private AppUser userB;
|
||||||
|
private AppUser userC;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
notificationService = new NotificationService(notificationRepository, userService, documentService, Optional.of(mailSender), sseEmitterRegistry);
|
||||||
|
|
||||||
|
userA = AppUser.builder().id(UUID.randomUUID()).username("userA")
|
||||||
|
.firstName("Anna").lastName("Smith").email("a@test.com")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
userB = AppUser.builder().id(UUID.randomUUID()).username("userB")
|
||||||
|
.firstName("Bob").lastName("Jones").email("b@test.com")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
userC = AppUser.builder().id(UUID.randomUUID()).username("userC")
|
||||||
|
.firstName("Clara").lastName("Doe").email("c@test.com")
|
||||||
|
.notifyOnReply(false).notifyOnMention(false).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── notifyReply ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_createsNotificationForThreadParticipants() {
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
|
||||||
|
|
||||||
|
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
||||||
|
verify(notificationRepository, times(2)).save(captor.capture());
|
||||||
|
|
||||||
|
List<Notification> saved = captor.getAllValues();
|
||||||
|
assertThat(saved).extracting(n -> n.getRecipient().getId())
|
||||||
|
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||||
|
assertThat(saved).allMatch(n -> n.getType() == NotificationType.REPLY);
|
||||||
|
assertThat(saved).allMatch(n -> !n.isRead());
|
||||||
|
assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_doesNothing_whenParticipantSetIsEmpty() {
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, Set.of());
|
||||||
|
|
||||||
|
verify(notificationRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_sendsEmailOnlyToUsersWithReplyNotificationsEnabled() {
|
||||||
|
userA.setNotifyOnReply(true);
|
||||||
|
userB.setNotifyOnReply(false);
|
||||||
|
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
|
||||||
|
|
||||||
|
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── notifyMentions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyMentions_createsNotificationPerMentionedUser() {
|
||||||
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||||
|
|
||||||
|
ArgumentCaptor<Notification> captor = ArgumentCaptor.forClass(Notification.class);
|
||||||
|
verify(notificationRepository, times(2)).save(captor.capture());
|
||||||
|
|
||||||
|
List<Notification> saved = captor.getAllValues();
|
||||||
|
assertThat(saved).extracting(n -> n.getRecipient().getId())
|
||||||
|
.containsExactlyInAnyOrder(userA.getId(), userB.getId());
|
||||||
|
assertThat(saved).allMatch(n -> n.getType() == NotificationType.MENTION);
|
||||||
|
assertThat(saved).allMatch(n -> "Clara Doe".equals(n.getActorName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyMentions_doesNothing_whenListIsEmpty() {
|
||||||
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
|
||||||
|
|
||||||
|
notificationService.notifyMentions(List.of(), comment);
|
||||||
|
|
||||||
|
verify(notificationRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyMentions_sendsEmailOnlyToUsersWithMentionNotificationsEnabled() {
|
||||||
|
userA.setNotifyOnMention(true);
|
||||||
|
userB.setNotifyOnMention(false);
|
||||||
|
|
||||||
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||||
|
|
||||||
|
verify(mailSender, times(1)).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── SSE push ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_pushesEventToRegistry_forEachRecipient() {
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(Set.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, Set.of(userA.getId(), userB.getId()));
|
||||||
|
|
||||||
|
verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class));
|
||||||
|
verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyMentions_pushesEventToRegistry_forEachMentionedUser() {
|
||||||
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(List.of(userA.getId(), userB.getId()))).thenReturn(List.of(userA, userB));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyMentions(List.of(userA.getId(), userB.getId()), comment);
|
||||||
|
|
||||||
|
verify(sseEmitterRegistry).send(eq(userA.getId()), any(NotificationDTO.class));
|
||||||
|
verify(sseEmitterRegistry).send(eq(userB.getId()), any(NotificationDTO.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── markRead ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markRead_throwsNotFound_whenNotificationDoesNotExist() {
|
||||||
|
UUID notifId = UUID.randomUUID();
|
||||||
|
when(notificationRepository.findById(notifId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> notificationService.markRead(notifId, userA.getId()))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("Notification not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markRead_throwsForbidden_whenNotificationBelongsToDifferentUser() {
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.recipient(userA)
|
||||||
|
.type(NotificationType.REPLY)
|
||||||
|
.read(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(notificationRepository.findById(notification.getId())).thenReturn(Optional.of(notification));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> notificationService.markRead(notification.getId(), userB.getId()))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("different user");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── markAllRead ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markAllRead_delegatesToRepository() {
|
||||||
|
notificationService.markAllRead(userA.getId());
|
||||||
|
|
||||||
|
verify(notificationRepository).markAllReadByRecipientId(userA.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── markRead — happy path ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void markRead_marksNotificationAsRead_whenRecipientMatches() {
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.recipient(userA)
|
||||||
|
.type(NotificationType.REPLY)
|
||||||
|
.documentId(UUID.randomUUID())
|
||||||
|
.referenceId(UUID.randomUUID())
|
||||||
|
.read(false)
|
||||||
|
.build();
|
||||||
|
when(notificationRepository.findById(notification.getId())).thenReturn(Optional.of(notification));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
NotificationDTO result = notificationService.markRead(notification.getId(), userA.getId());
|
||||||
|
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
assertThat(notification.isRead()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── countUnread ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void countUnread_delegatesToRepository() {
|
||||||
|
when(notificationRepository.countByRecipientIdAndReadFalse(userA.getId())).thenReturn(3L);
|
||||||
|
|
||||||
|
assertThat(notificationService.countUnread(userA.getId())).isEqualTo(3L);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── notifyMentions — null list ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyMentions_doesNothing_whenMentionedUserIdsIsNull() {
|
||||||
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userA.getId(), "Anna Smith");
|
||||||
|
|
||||||
|
notificationService.notifyMentions(null, comment);
|
||||||
|
|
||||||
|
verify(notificationRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── email — no mailSender ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_skipsEmail_whenMailSenderIsAbsent() {
|
||||||
|
NotificationService serviceWithoutMail = new NotificationService(
|
||||||
|
notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry);
|
||||||
|
|
||||||
|
userA.setNotifyOnReply(true);
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
serviceWithoutMail.notifyReply(reply, Set.of(userA.getId()));
|
||||||
|
|
||||||
|
verify(mailSender, never()).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyMentions_skipsEmail_whenMailSenderIsAbsent() {
|
||||||
|
NotificationService serviceWithoutMail = new NotificationService(
|
||||||
|
notificationRepository, userService, documentService, Optional.empty(), sseEmitterRegistry);
|
||||||
|
|
||||||
|
userA.setNotifyOnMention(true);
|
||||||
|
DocumentComment comment = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(List.of(userA.getId()))).thenReturn(List.of(userA));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
serviceWithoutMail.notifyMentions(List.of(userA.getId()), comment);
|
||||||
|
|
||||||
|
verify(mailSender, never()).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── email — recipient email missing ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_skipsEmail_whenRecipientEmailIsNull() {
|
||||||
|
userA.setNotifyOnReply(true);
|
||||||
|
userA.setEmail(null);
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, Set.of(userA.getId()));
|
||||||
|
|
||||||
|
verify(mailSender, never()).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_skipsEmail_whenRecipientEmailIsBlank() {
|
||||||
|
userA.setNotifyOnReply(true);
|
||||||
|
userA.setEmail(" ");
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, Set.of(userA.getId()));
|
||||||
|
|
||||||
|
verify(mailSender, never()).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── email — MailException swallowed ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_doesNotThrow_whenMailExceptionOccurs() {
|
||||||
|
userA.setNotifyOnReply(true);
|
||||||
|
DocumentComment reply = commentWithAuthor(UUID.randomUUID(), null, userC.getId(), "Clara Doe");
|
||||||
|
|
||||||
|
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
doThrow(new MailSendException("SMTP down")).when(mailSender).send(any(SimpleMailMessage.class));
|
||||||
|
|
||||||
|
// Must not throw — MailException is caught and logged
|
||||||
|
notificationService.notifyReply(reply, Set.of(userA.getId()));
|
||||||
|
|
||||||
|
verify(mailSender).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── email — annotationId included in link ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void notifyReply_includesAnnotationIdInEmailLink_whenAnnotationPresent() {
|
||||||
|
userA.setNotifyOnReply(true);
|
||||||
|
UUID annotationId = UUID.randomUUID();
|
||||||
|
DocumentComment reply = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.documentId(UUID.randomUUID())
|
||||||
|
.annotationId(annotationId)
|
||||||
|
.authorId(userC.getId())
|
||||||
|
.authorName("Clara Doe")
|
||||||
|
.content("reply")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
when(userService.findAllById(Set.of(userA.getId()))).thenReturn(List.of(userA));
|
||||||
|
when(notificationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
notificationService.notifyReply(reply, Set.of(userA.getId()));
|
||||||
|
|
||||||
|
ArgumentCaptor<SimpleMailMessage> captor = ArgumentCaptor.forClass(SimpleMailMessage.class);
|
||||||
|
verify(mailSender).send(captor.capture());
|
||||||
|
assertThat(captor.getValue().getText()).contains("annotationId=" + annotationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getNotifications — filter dispatch ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_withNoFilters_usesUnfilteredRepoMethod() {
|
||||||
|
when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()))
|
||||||
|
.thenReturn(Page.empty());
|
||||||
|
|
||||||
|
notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
verify(notificationRepository).findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any());
|
||||||
|
verify(notificationRepository, never())
|
||||||
|
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_withTypeAndReadFalse_usesFilteredRepoMethod() {
|
||||||
|
when(notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), eq(NotificationType.MENTION), any()))
|
||||||
|
.thenReturn(Page.empty());
|
||||||
|
|
||||||
|
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, false, Pageable.ofSize(3));
|
||||||
|
|
||||||
|
verify(notificationRepository).findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), eq(NotificationType.MENTION), any());
|
||||||
|
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_withTypeOnly_usesTypeFilteredRepoMethod() {
|
||||||
|
when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), eq(NotificationType.MENTION), any()))
|
||||||
|
.thenReturn(Page.empty());
|
||||||
|
|
||||||
|
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, null, Pageable.ofSize(5));
|
||||||
|
|
||||||
|
verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), eq(NotificationType.MENTION), any());
|
||||||
|
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
|
||||||
|
verify(notificationRepository, never())
|
||||||
|
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_withReadFalseAndNoType_usesUnreadOnlyRepoMethod() {
|
||||||
|
when(notificationRepository.findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), any()))
|
||||||
|
.thenReturn(Page.empty());
|
||||||
|
|
||||||
|
notificationService.getNotifications(userA.getId(), null, false, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
verify(notificationRepository).findByRecipientIdAndReadFalseOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), any());
|
||||||
|
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_mapsDocumentTitleFromDocumentService() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.recipient(userA)
|
||||||
|
.type(NotificationType.REPLY)
|
||||||
|
.documentId(docId)
|
||||||
|
.referenceId(UUID.randomUUID())
|
||||||
|
.actorName("Clara Doe")
|
||||||
|
.build();
|
||||||
|
when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(notification)));
|
||||||
|
when(documentService.findTitlesByIds(Set.of(docId)))
|
||||||
|
.thenReturn(Map.of(docId, "Geburtsurkunde Opa Karl"));
|
||||||
|
|
||||||
|
Page<NotificationDTO> result = notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(1);
|
||||||
|
assertThat(result.getContent().getFirst().documentTitle()).isEqualTo("Geburtsurkunde Opa Karl");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_mapsDocumentTitleAsNull_whenDocumentDoesNotExist() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Notification notification = Notification.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.recipient(userA)
|
||||||
|
.type(NotificationType.MENTION)
|
||||||
|
.documentId(docId)
|
||||||
|
.referenceId(UUID.randomUUID())
|
||||||
|
.actorName("Bob Jones")
|
||||||
|
.build();
|
||||||
|
when(notificationRepository.findByRecipientIdOrderByCreatedAtDesc(eq(userA.getId()), any()))
|
||||||
|
.thenReturn(new PageImpl<>(List.of(notification)));
|
||||||
|
when(documentService.findTitlesByIds(Set.of(docId)))
|
||||||
|
.thenReturn(Map.of());
|
||||||
|
|
||||||
|
Page<NotificationDTO> result = notificationService.getNotifications(userA.getId(), null, null, Pageable.ofSize(10));
|
||||||
|
|
||||||
|
assertThat(result.getContent()).hasSize(1);
|
||||||
|
assertThat(result.getContent().getFirst().documentTitle()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() {
|
||||||
|
// read=true with a type filter falls through to the type-only branch —
|
||||||
|
// it returns all notifications of that type (both read and unread).
|
||||||
|
// The read=true filter is intentionally not supported on the backend;
|
||||||
|
// callers that need only-read results must filter client-side.
|
||||||
|
when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), eq(NotificationType.MENTION), any()))
|
||||||
|
.thenReturn(Page.empty());
|
||||||
|
|
||||||
|
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, true, Pageable.ofSize(5));
|
||||||
|
|
||||||
|
verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||||
|
eq(userA.getId()), eq(NotificationType.MENTION), any());
|
||||||
|
verify(notificationRepository, never())
|
||||||
|
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
|
||||||
|
return DocumentComment.builder()
|
||||||
|
.id(id)
|
||||||
|
.documentId(UUID.randomUUID())
|
||||||
|
.parentId(parentId)
|
||||||
|
.authorId(authorId)
|
||||||
|
.authorName(authorName)
|
||||||
|
.content("content")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.argThat;
|
|||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -23,8 +24,11 @@ import org.raddatz.familienarchiv.model.AppUser;
|
|||||||
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
||||||
|
import org.springframework.mail.MailSendException;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
import org.springframework.mail.javamail.JavaMailSender;
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class PasswordResetServiceTest {
|
class PasswordResetServiceTest {
|
||||||
@@ -123,4 +127,62 @@ class PasswordResetServiceTest {
|
|||||||
assertThatThrownBy(() -> service.resetPassword(req))
|
assertThatThrownBy(() -> service.resetPassword(req))
|
||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetPassword_throwsForAlreadyUsedToken() {
|
||||||
|
AppUser user = makeUser("user@example.com");
|
||||||
|
PasswordResetToken token = PasswordResetToken.builder()
|
||||||
|
.token("usedtoken")
|
||||||
|
.user(user)
|
||||||
|
.expiresAt(LocalDateTime.now().plusHours(1))
|
||||||
|
.used(true) // already used
|
||||||
|
.build();
|
||||||
|
when(tokenRepository.findByToken("usedtoken")).thenReturn(Optional.of(token));
|
||||||
|
|
||||||
|
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||||
|
req.setToken("usedtoken");
|
||||||
|
req.setNewPassword("newpass");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.resetPassword(req))
|
||||||
|
.isInstanceOf(DomainException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── requestReset — mail sending branches ─────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requestReset_skipsEmail_whenMailSenderIsNull() {
|
||||||
|
ReflectionTestUtils.setField(service, "mailSender", null);
|
||||||
|
AppUser user = makeUser("user@example.com");
|
||||||
|
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
// Must not throw even without mail sender
|
||||||
|
service.requestReset("user@example.com", "http://localhost:3000");
|
||||||
|
|
||||||
|
verify(tokenRepository).save(any());
|
||||||
|
verify(mailSender, never()).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requestReset_logsError_whenMailExceptionThrown() {
|
||||||
|
// mailSender is @Autowired(required=false) — not in constructor, so needs explicit injection
|
||||||
|
ReflectionTestUtils.setField(service, "mailSender", mailSender);
|
||||||
|
AppUser user = makeUser("user@example.com");
|
||||||
|
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||||
|
doThrow(new MailSendException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class));
|
||||||
|
|
||||||
|
// Must not propagate the MailException
|
||||||
|
service.requestReset("user@example.com", "http://localhost:3000");
|
||||||
|
|
||||||
|
verify(tokenRepository).save(any());
|
||||||
|
verify(mailSender).send(any(SimpleMailMessage.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── cleanupExpiredTokens ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleanupExpiredTokens_delegatesToRepository() {
|
||||||
|
service.cleanupExpiredTokens();
|
||||||
|
|
||||||
|
verify(tokenRepository).deleteExpiredAndUsed(any(LocalDateTime.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,4 +117,50 @@ class PersonNameParserTest {
|
|||||||
assertThat(result.firstName()).isEqualTo("?");
|
assertThat(result.firstName()).isEqualTo("?");
|
||||||
assertThat(result.lastName()).isEqualTo("?");
|
assertThat(result.lastName()).isEqualTo("?");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void split_blank_returnsPlaceholder() {
|
||||||
|
PersonNameParser.SplitName result = PersonNameParser.split(" ");
|
||||||
|
assertThat(result.firstName()).isEqualTo("?");
|
||||||
|
assertThat(result.lastName()).isEqualTo("?");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void split_onlyKnownLastName_firstNameFallsBackToCleaned() {
|
||||||
|
// "de Gruyter" alone → firstName would be blank after removing last name, so cleaned is used
|
||||||
|
PersonNameParser.SplitName result = PersonNameParser.split("de Gruyter");
|
||||||
|
assertThat(result.firstName()).isEqualTo("de Gruyter");
|
||||||
|
assertThat(result.lastName()).isEqualTo("de Gruyter");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- parseReceivers — shared last name with full-name part ─────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseReceivers_partWithSpace_notAppended_whenParenLastNamePresent() {
|
||||||
|
// "Clara Cram und Hans (Müller)": Clara Cram already has a space → keep as-is
|
||||||
|
List<String> result = PersonNameParser.parseReceivers("Clara Cram und Hans (Müller)");
|
||||||
|
assertThat(result).containsExactlyInAnyOrder("Clara Cram", "Hans Müller");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseReceivers_partAlreadyFullName_notDistributed_fromLastSegmentLastName() {
|
||||||
|
// "Clara Cram und Eugenie de Gruyter": first part has its own name, no distribution
|
||||||
|
List<String> result = PersonNameParser.parseReceivers("Clara Cram und Eugenie de Gruyter");
|
||||||
|
assertThat(result).containsExactlyInAnyOrder("Clara Cram", "Eugenie de Gruyter");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseReceivers_returnsEmpty_whenAllPartsAreFamilie() {
|
||||||
|
// All parts filtered out → nameParts.isEmpty() = true → return List.of()
|
||||||
|
assertThat(PersonNameParser.parseReceivers("Familie und Familie")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void parseReceivers_singleTokenKnownLastName_notDistributed() {
|
||||||
|
// "Müller und Herbert de Gruyter":
|
||||||
|
// last segment = "Herbert de Gruyter" → detectedLastName = "de Gruyter"
|
||||||
|
// "Müller": !contains(" ") = true BUT findKnownLastName("Müller") != null → else branch → kept as-is
|
||||||
|
List<String> result = PersonNameParser.parseReceivers("Müller und Herbert de Gruyter");
|
||||||
|
assertThat(result).containsExactlyInAnyOrder("Müller", "Herbert de Gruyter");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
@@ -33,8 +35,8 @@ class PersonServiceTest {
|
|||||||
when(personRepository.findById(id)).thenReturn(Optional.empty());
|
when(personRepository.findById(id)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> personService.getById(id))
|
assertThatThrownBy(() -> personService.getById(id))
|
||||||
.isInstanceOf(ResponseStatusException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
.isEqualTo(404);
|
.isEqualTo(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,126 @@ class PersonServiceTest {
|
|||||||
assertThat(personService.getById(id)).isEqualTo(person);
|
assertThat(personService.getById(id)).isEqualTo(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── findAll ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_returnsAll_whenQueryIsNull() {
|
||||||
|
List<PersonSummaryDTO> expected = List.of();
|
||||||
|
when(personRepository.findAllWithDocumentCount()).thenReturn(expected);
|
||||||
|
|
||||||
|
assertThat(personService.findAll(null)).isEqualTo(expected);
|
||||||
|
verify(personRepository).findAllWithDocumentCount();
|
||||||
|
verify(personRepository, never()).searchWithDocumentCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_returnsEmpty_whenQueryIsWhitespaceOnly() {
|
||||||
|
assertThat(personService.findAll(" ")).isEmpty();
|
||||||
|
verify(personRepository, never()).findAllWithDocumentCount();
|
||||||
|
verify(personRepository, never()).searchWithDocumentCount(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findAll_searchesByName_whenQueryIsNonBlank() {
|
||||||
|
List<PersonSummaryDTO> expected = List.of();
|
||||||
|
when(personRepository.searchWithDocumentCount("Anna")).thenReturn(expected);
|
||||||
|
|
||||||
|
assertThat(personService.findAll("Anna")).isEqualTo(expected);
|
||||||
|
verify(personRepository).searchWithDocumentCount("Anna");
|
||||||
|
verify(personRepository, never()).findAllWithDocumentCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createPerson ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_savesPersonWithNullAlias_whenAliasIsNull() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Person result = personService.createPerson("Hans", "Müller", null);
|
||||||
|
|
||||||
|
assertThat(result.getAlias()).isNull();
|
||||||
|
verify(personRepository).save(argThat(p -> p.getFirstName().equals("Hans") && p.getAlias() == null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_savesPersonWithNullAlias_whenAliasIsBlank() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Person result = personService.createPerson("Hans", "Müller", " ");
|
||||||
|
|
||||||
|
assertThat(result.getAlias()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_savesTrimmedAlias_whenAliasIsNonBlank() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
Person result = personService.createPerson("Hans", "Müller", " Hans Müller ");
|
||||||
|
|
||||||
|
assertThat(result.getAlias()).isEqualTo("Hans Müller");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Phase 2.1: createPerson(PersonUpdateDTO) ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_persistsAllSixFields() {
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Maria"); dto.setLastName("Raddatz"); dto.setAlias("Oma Maria");
|
||||||
|
dto.setBirthYear(1901); dto.setDeathYear(1975); dto.setNotes("Some notes");
|
||||||
|
|
||||||
|
Person result = personService.createPerson(dto);
|
||||||
|
|
||||||
|
assertThat(result.getFirstName()).isEqualTo("Maria");
|
||||||
|
assertThat(result.getLastName()).isEqualTo("Raddatz");
|
||||||
|
assertThat(result.getAlias()).isEqualTo("Oma Maria");
|
||||||
|
assertThat(result.getBirthYear()).isEqualTo(1901);
|
||||||
|
assertThat(result.getDeathYear()).isEqualTo(1975);
|
||||||
|
assertThat(result.getNotes()).isEqualTo("Some notes");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createPerson_dto_yearValidationFires_whenBirthYearNegative() {
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Test"); dto.setBirthYear(-1);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> personService.createPerson(dto))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updatePerson (alias) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_setsNullAlias_whenAliasIsBlank() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").alias("old alias").build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setAlias(" ");
|
||||||
|
Person result = personService.updatePerson(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getAlias()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_setsTrimmedAlias_whenAliasIsNonBlank() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setAlias(" Anna Alt ");
|
||||||
|
Person result = personService.updatePerson(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getAlias()).isEqualTo("Anna Alt");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -144,6 +266,22 @@ class PersonServiceTest {
|
|||||||
.isEqualTo(400);
|
.isEqualTo(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_doesNotThrow_whenBirthYearNonNullButDeathYearIsNull() {
|
||||||
|
// Covers A && B short-circuit: birthYear != null (true) but deathYear == null (false) → no throw
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build();
|
||||||
|
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||||
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(1890); dto.setDeathYear(null);
|
||||||
|
Person result = personService.updatePerson(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getBirthYear()).isEqualTo(1890);
|
||||||
|
assertThat(result.getDeathYear()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updatePerson_allowsSameYear() {
|
void updatePerson_allowsSameYear() {
|
||||||
UUID id = UUID.randomUUID();
|
UUID id = UUID.randomUUID();
|
||||||
@@ -159,6 +297,56 @@ class PersonServiceTest {
|
|||||||
assertThat(result.getDeathYear()).isEqualTo(1900);
|
assertThat(result.getDeathYear()).isEqualTo(1900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Phase 1.3: Year range bounds (> 0) ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_throwsBadRequest_whenBirthYearIsZero() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(0);
|
||||||
|
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_throwsBadRequest_whenBirthYearIsNegative() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setBirthYear(-5);
|
||||||
|
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_throwsBadRequest_whenDeathYearIsZero() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(0);
|
||||||
|
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updatePerson_throwsBadRequest_whenDeathYearIsNegative() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
|
||||||
|
PersonUpdateDTO dto = new PersonUpdateDTO();
|
||||||
|
dto.setFirstName("Anna"); dto.setLastName("Alt"); dto.setDeathYear(-10);
|
||||||
|
assertThatThrownBy(() -> personService.updatePerson(id, dto))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── findCorrespondents ──────────────────────────────────────────────────
|
// ─── findCorrespondents ──────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -213,8 +401,8 @@ class PersonServiceTest {
|
|||||||
when(personRepository.findById(sourceId)).thenReturn(Optional.empty());
|
when(personRepository.findById(sourceId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
|
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
|
||||||
.isInstanceOf(ResponseStatusException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
.isEqualTo(404);
|
.isEqualTo(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +415,8 @@ class PersonServiceTest {
|
|||||||
when(personRepository.findById(targetId)).thenReturn(Optional.empty());
|
when(personRepository.findById(targetId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
|
assertThatThrownBy(() -> personService.mergePersons(sourceId, targetId))
|
||||||
.isInstanceOf(ResponseStatusException.class)
|
.isInstanceOf(DomainException.class)
|
||||||
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
.extracting(e -> ((DomainException) e).getStatus().value())
|
||||||
.isEqualTo(404);
|
.isEqualTo(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
|
||||||
|
class SseEmitterRegistryTest {
|
||||||
|
|
||||||
|
private final SseEmitterRegistry registry = new SseEmitterRegistry();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_returnsEmitter() {
|
||||||
|
SseEmitter emitter = registry.register(UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(emitter).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void send_doesNothing_whenNoEmitterRegistered() {
|
||||||
|
assertThatCode(() -> registry.send(UUID.randomUUID(), "data"))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void register_replacesExistingEmitter_forSameUser() {
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
|
||||||
|
SseEmitter first = registry.register(userId);
|
||||||
|
SseEmitter second = registry.register(userId);
|
||||||
|
|
||||||
|
assertThat(first).isNotSameAs(second);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void send_doesNotThrow_whenEmitterRegistered_andSendFails() {
|
||||||
|
// Registering an emitter without an active HTTP connection causes IOException on send
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
registry.register(userId);
|
||||||
|
|
||||||
|
// Must not propagate the IOException — it's caught and the emitter is removed
|
||||||
|
assertThatCode(() -> registry.send(userId, "data")).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
|
||||||
|
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
|
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||||
|
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
|
||||||
|
import org.raddatz.familienarchiv.repository.TranscriptionBlockVersionRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
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 static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class TranscriptionServiceTest {
|
||||||
|
|
||||||
|
@Mock TranscriptionBlockRepository blockRepository;
|
||||||
|
@Mock TranscriptionBlockVersionRepository versionRepository;
|
||||||
|
@Mock AnnotationRepository annotationRepository;
|
||||||
|
@Mock AnnotationService annotationService;
|
||||||
|
@Mock DocumentService documentService;
|
||||||
|
@InjectMocks TranscriptionService transcriptionService;
|
||||||
|
|
||||||
|
// ─── getBlock ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBlock_throwsNotFound_whenBlockDoesNotExist() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> transcriptionService.getBlock(docId, blockId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBlock_returnsBlock_whenExists() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).text("hello").build();
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||||
|
|
||||||
|
TranscriptionBlock result = transcriptionService.getBlock(docId, blockId);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createBlock ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createBlock_createsAnnotationAndBlockAndVersion() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
UUID annotId = UUID.randomUUID();
|
||||||
|
|
||||||
|
Document doc = Document.builder().id(docId).fileHash("hash123").build();
|
||||||
|
when(documentService.getDocumentById(docId)).thenReturn(doc);
|
||||||
|
|
||||||
|
DocumentAnnotation annotation = DocumentAnnotation.builder().id(annotId).build();
|
||||||
|
when(annotationService.createAnnotation(eq(docId), any(CreateAnnotationDTO.class), eq(userId), eq("hash123")))
|
||||||
|
.thenReturn(annotation);
|
||||||
|
|
||||||
|
when(blockRepository.countByDocumentId(docId)).thenReturn(0);
|
||||||
|
when(blockRepository.save(any())).thenAnswer(inv -> {
|
||||||
|
TranscriptionBlock b = inv.getArgument(0);
|
||||||
|
b.setId(UUID.randomUUID());
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
|
||||||
|
CreateTranscriptionBlockDTO dto = new CreateTranscriptionBlockDTO(1, 0.1, 0.2, 0.3, 0.4, "hello", null);
|
||||||
|
|
||||||
|
TranscriptionBlock result = transcriptionService.createBlock(docId, dto, userId);
|
||||||
|
|
||||||
|
assertThat(result.getAnnotationId()).isEqualTo(annotId);
|
||||||
|
assertThat(result.getText()).isEqualTo("hello");
|
||||||
|
assertThat(result.getSortOrder()).isZero();
|
||||||
|
assertThat(result.getCreatedBy()).isEqualTo(userId);
|
||||||
|
verify(versionRepository).save(any(TranscriptionBlockVersion.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updateBlock ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBlock_updatesTextAndSavesVersion() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).text("old").build();
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||||
|
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("new text", null);
|
||||||
|
|
||||||
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, userId);
|
||||||
|
|
||||||
|
assertThat(result.getText()).isEqualTo("new text");
|
||||||
|
assertThat(result.getUpdatedBy()).isEqualTo(userId);
|
||||||
|
verify(versionRepository).save(any(TranscriptionBlockVersion.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateBlock_updatesLabel_whenProvided() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).text("text").label("old label").build();
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||||
|
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
UpdateTranscriptionBlockDTO dto = new UpdateTranscriptionBlockDTO("text", "Anrede");
|
||||||
|
|
||||||
|
TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID());
|
||||||
|
|
||||||
|
assertThat(result.getLabel()).isEqualTo("Anrede");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── deleteBlock ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteBlock_deletesBlockAndAnnotation() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
UUID annotId = UUID.randomUUID();
|
||||||
|
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).annotationId(annotId).build();
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||||
|
|
||||||
|
transcriptionService.deleteBlock(docId, blockId);
|
||||||
|
|
||||||
|
verify(blockRepository).delete(block);
|
||||||
|
verify(blockRepository).flush();
|
||||||
|
verify(annotationRepository).deleteById(annotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteBlock_throwsNotFound_whenBlockMissing() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> transcriptionService.deleteBlock(docId, blockId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── reorderBlocks ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reorderBlocks_updatesSortOrder() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
|
||||||
|
TranscriptionBlock block1 = TranscriptionBlock.builder()
|
||||||
|
.id(id1).documentId(docId).sortOrder(0).build();
|
||||||
|
TranscriptionBlock block2 = TranscriptionBlock.builder()
|
||||||
|
.id(id2).documentId(docId).sortOrder(1).build();
|
||||||
|
|
||||||
|
when(blockRepository.findByIdAndDocumentId(id2, docId)).thenReturn(Optional.of(block2));
|
||||||
|
when(blockRepository.findByIdAndDocumentId(id1, docId)).thenReturn(Optional.of(block1));
|
||||||
|
when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
ReorderTranscriptionBlocksDTO dto = new ReorderTranscriptionBlocksDTO(List.of(id2, id1));
|
||||||
|
|
||||||
|
transcriptionService.reorderBlocks(docId, dto);
|
||||||
|
|
||||||
|
assertThat(block2.getSortOrder()).isZero();
|
||||||
|
assertThat(block1.getSortOrder()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getBlockHistory ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getBlockHistory_returnsVersionsForBlock() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID blockId = UUID.randomUUID();
|
||||||
|
|
||||||
|
TranscriptionBlock block = TranscriptionBlock.builder()
|
||||||
|
.id(blockId).documentId(docId).build();
|
||||||
|
when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block));
|
||||||
|
|
||||||
|
TranscriptionBlockVersion v = TranscriptionBlockVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).blockId(blockId).text("ver1").build();
|
||||||
|
when(versionRepository.findByBlockIdOrderByChangedAtDesc(blockId)).thenReturn(List.of(v));
|
||||||
|
|
||||||
|
List<TranscriptionBlockVersion> result = transcriptionService.getBlockHistory(docId, blockId);
|
||||||
|
|
||||||
|
assertThat(result).containsExactly(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── sanitizeText ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeText_returnsEmptyString_forNull() {
|
||||||
|
assertThat(transcriptionService.sanitizeText(null)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeText_truncatesAtMaxLength() {
|
||||||
|
String longText = "a".repeat(15_000);
|
||||||
|
String result = transcriptionService.sanitizeText(longText);
|
||||||
|
assertThat(result).hasSize(10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sanitizeText_preservesPlainText() {
|
||||||
|
assertThat(transcriptionService.sanitizeText("Liebe Mutter,")).isEqualTo("Liebe Mutter,");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── listBlocks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listBlocks_returnsBlocksOrderedBySortOrder() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
TranscriptionBlock b = TranscriptionBlock.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).sortOrder(0).build();
|
||||||
|
when(blockRepository.findByDocumentIdOrderBySortOrderAsc(docId)).thenReturn(List.of(b));
|
||||||
|
|
||||||
|
assertThat(transcriptionService.listBlocks(docId)).containsExactly(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
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.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserSearchServiceTest {
|
||||||
|
|
||||||
|
@Mock AppUserRepository userRepository;
|
||||||
|
@InjectMocks UserSearchService userSearchService;
|
||||||
|
|
||||||
|
// ─── search ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_returnsEmpty_whenQueryIsNull() {
|
||||||
|
List<AppUser> result = userSearchService.search(null);
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
verify(userRepository, never()).searchByNameOrUsername(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_returnsEmpty_whenQueryIsBlank() {
|
||||||
|
List<AppUser> result = userSearchService.search(" ");
|
||||||
|
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
verify(userRepository, never()).searchByNameOrUsername(any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_delegatesToRepository_whenQueryIsNonBlank() {
|
||||||
|
AppUser user = AppUser.builder().id(UUID.randomUUID()).username("hans").build();
|
||||||
|
when(userRepository.searchByNameOrUsername(eq("hans"), any(PageRequest.class)))
|
||||||
|
.thenReturn(List.of(user));
|
||||||
|
|
||||||
|
List<AppUser> result = userSearchService.search("hans");
|
||||||
|
|
||||||
|
assertThat(result).containsExactly(user);
|
||||||
|
verify(userRepository).searchByNameOrUsername(eq("hans"), any(PageRequest.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void search_trimsQuery_beforeDelegating() {
|
||||||
|
when(userRepository.searchByNameOrUsername(eq("hans"), any(PageRequest.class)))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
userSearchService.search(" hans ");
|
||||||
|
|
||||||
|
verify(userRepository).searchByNameOrUsername(eq("hans"), any(PageRequest.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,17 +5,20 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.raddatz.familienarchiv.dto.AdminUpdateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
import org.raddatz.familienarchiv.dto.ChangePasswordDTO;
|
||||||
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
import org.raddatz.familienarchiv.dto.CreateUserRequest;
|
||||||
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
import org.raddatz.familienarchiv.dto.UpdateProfileDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.model.AppUser;
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||||
import org.raddatz.familienarchiv.repository.UserGroupRepository;
|
import org.raddatz.familienarchiv.repository.UserGroupRepository;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -216,6 +219,78 @@ class UserServiceTest {
|
|||||||
verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword())));
|
verify(userRepository).save(argThat(u -> "newHash".equals(u.getPassword())));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── adminUpdateUser ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_updatesNameFields() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setFirstName("Ada"); dto.setLastName("Lovelace");
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getFirstName()).isEqualTo("Ada");
|
||||||
|
assertThat(result.getLastName()).isEqualTo("Lovelace");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_preservesGroups_whenGroupIdsIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UserGroup adminGroup = UserGroup.builder().id(UUID.randomUUID()).name("Administrators").build();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").groups(Set.of(adminGroup)).build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setFirstName("Ada"); // groupIds left null → don't change groups
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getGroups()).containsExactly(adminGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_updatesGroups_whenGroupIdsProvided() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UserGroup oldGroup = UserGroup.builder().id(UUID.randomUUID()).name("Viewers").build();
|
||||||
|
UserGroup newGroup = UserGroup.builder().id(UUID.randomUUID()).name("Editors").build();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("max").groups(Set.of(oldGroup)).build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(groupRepository.findAllById(List.of(newGroup.getId()))).thenReturn(List.of(newGroup));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setGroupIds(List.of(newGroup.getId()));
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getGroups()).containsExactly(newGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_clearsGroups_whenGroupIdsIsEmptyList() {
|
||||||
|
// Sending groupIds:[] is the explicit "remove from all groups" signal.
|
||||||
|
// The frontend must NEVER send [] accidentally — it must always include
|
||||||
|
// the currently-selected group checkboxes.
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UserGroup adminGroup = UserGroup.builder().id(UUID.randomUUID()).name("Administrators").build();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").groups(Set.of(adminGroup)).build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(groupRepository.findAllById(List.of())).thenReturn(List.of());
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setGroupIds(List.of()); // empty list → intentional "remove all groups"
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getGroups()).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
// ─── getGroupById ─────────────────────────────────────────────────────────
|
// ─── getGroupById ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -226,4 +301,378 @@ class UserServiceTest {
|
|||||||
assertThatThrownBy(() -> userService.getGroupById(id))
|
assertThatThrownBy(() -> userService.getGroupById(id))
|
||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── createUserOrUpdate — groups loaded ───────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserOrUpdate_loadsGroups_whenGroupIdsNonEmpty() {
|
||||||
|
UserGroup group = UserGroup.builder().id(UUID.randomUUID()).name("Admins").build();
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setUsername("newuser");
|
||||||
|
req.setEmail("u@example.com");
|
||||||
|
req.setInitialPassword("pass");
|
||||||
|
req.setGroupIds(List.of(group.getId()));
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
|
||||||
|
when(groupRepository.findAllById(List.of(group.getId()))).thenReturn(List.of(group));
|
||||||
|
when(passwordEncoder.encode("pass")).thenReturn("encoded");
|
||||||
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
|
||||||
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
AppUser result = userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(saved);
|
||||||
|
verify(groupRepository).findAllById(List.of(group.getId()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updateProfile — email edge cases ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateProfile_setsEmailToNull_whenEmailIsBlank() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("max").email("old@example.com").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||||
|
dto.setEmail(" "); // blank — should clear email
|
||||||
|
|
||||||
|
AppUser result = userService.updateProfile(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getEmail()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateProfile_doesNotChangeEmail_whenEmailDtoIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("max").email("keep@example.com").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||||
|
dto.setEmail(null); // null — no change
|
||||||
|
|
||||||
|
AppUser result = userService.updateProfile(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateProfile_setsContactToNull_whenContactIsBlank() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||||
|
dto.setContact(" ");
|
||||||
|
|
||||||
|
AppUser result = userService.updateProfile(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getContact()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── adminUpdateUser — password and email branches ────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_setsPassword_whenNewPasswordProvided() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").password("old").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(passwordEncoder.encode("newSecret")).thenReturn("newHashed");
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setNewPassword("newSecret");
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getPassword()).isEqualTo("newHashed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_doesNotChangePassword_whenNewPasswordIsBlank() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").password("original").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setNewPassword(" ");
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getPassword()).isEqualTo("original");
|
||||||
|
verify(passwordEncoder, never()).encode(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_setsEmailToNull_whenEmailIsBlank() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").email("old@example.com").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setEmail(" ");
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getEmail()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_throwsConflict_whenEmailTakenByAnotherUser() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UUID otherId = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").build();
|
||||||
|
AppUser other = AppUser.builder().id(otherId).username("anna").email("taken@example.com").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.findByEmail("taken@example.com")).thenReturn(Optional.of(other));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setEmail("taken@example.com");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> userService.adminUpdateUser(id, dto))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.hasMessageContaining("E-Mail");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updateGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateGroup_updatesNameAndPermissions_whenBothProvided() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UserGroup group = UserGroup.builder().id(id).name("OldName")
|
||||||
|
.permissions(Set.of("READ_ALL")).build();
|
||||||
|
when(groupRepository.findById(id)).thenReturn(Optional.of(group));
|
||||||
|
when(groupRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
|
||||||
|
dto.setName("NewName");
|
||||||
|
dto.setPermissions(Set.of("WRITE_ALL"));
|
||||||
|
|
||||||
|
UserGroup result = userService.updateGroup(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getName()).isEqualTo("NewName");
|
||||||
|
assertThat(result.getPermissions()).containsExactly("WRITE_ALL");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateGroup_keepsExistingName_whenNameIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UserGroup group = UserGroup.builder().id(id).name("Existing").build();
|
||||||
|
when(groupRepository.findById(id)).thenReturn(Optional.of(group));
|
||||||
|
when(groupRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
|
||||||
|
dto.setName(null);
|
||||||
|
dto.setPermissions(Set.of("ADMIN"));
|
||||||
|
|
||||||
|
UserGroup result = userService.updateGroup(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getName()).isEqualTo("Existing");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateGroup_keepsExistingPermissions_whenPermissionsAreNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
UserGroup group = UserGroup.builder().id(id).name("Group")
|
||||||
|
.permissions(Set.of("READ_ALL")).build();
|
||||||
|
when(groupRepository.findById(id)).thenReturn(Optional.of(group));
|
||||||
|
when(groupRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
|
||||||
|
dto.setName("NewName");
|
||||||
|
dto.setPermissions(null);
|
||||||
|
|
||||||
|
UserGroup result = userService.updateGroup(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getPermissions()).containsExactly("READ_ALL");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createUserOrUpdate — empty groupIds ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserOrUpdate_doesNotLoadGroups_whenGroupIdsIsEmpty() {
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setUsername("newuser");
|
||||||
|
req.setEmail("u@example.com");
|
||||||
|
req.setInitialPassword("pass");
|
||||||
|
req.setGroupIds(List.of()); // empty, not null
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("newuser")).thenReturn(Optional.empty());
|
||||||
|
when(passwordEncoder.encode("pass")).thenReturn("encoded");
|
||||||
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("newuser").build();
|
||||||
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
|
verify(groupRepository, never()).findAllById(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── updateProfile — contact null ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateProfile_setsTrimmedContact_whenContactIsNonBlank() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("max").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||||
|
dto.setContact(" phone: 999 ");
|
||||||
|
|
||||||
|
AppUser result = userService.updateProfile(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getContact()).isEqualTo("phone: 999");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateProfile_setsNullContact_whenContactIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("max").contact("old contact").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||||
|
dto.setContact(null);
|
||||||
|
|
||||||
|
AppUser result = userService.updateProfile(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getContact()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateProfile_allowsSameEmail_whenEmailBelongsToSameUser() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("max").email("me@example.com").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user)); // same user
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
UpdateProfileDTO dto = new UpdateProfileDTO();
|
||||||
|
dto.setEmail("me@example.com");
|
||||||
|
|
||||||
|
// Must not throw
|
||||||
|
AppUser result = userService.updateProfile(id, dto);
|
||||||
|
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── adminUpdateUser — contact null and email null ────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_setsNullContact_whenContactIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").contact("old contact").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setContact(null);
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getContact()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_setsNullContact_whenContactIsBlank() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").contact("old").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setContact(" ");
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getContact()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_setsTrimmedContact_whenContactIsNonBlank() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setContact(" phone: 555 ");
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getContact()).isEqualTo("phone: 555");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_doesNotModifyEmail_whenEmailIsNull() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").email("keep@example.com").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setEmail(null);
|
||||||
|
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
|
||||||
|
assertThat(result.getEmail()).isEqualTo("keep@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminUpdateUser_allowsSameEmail_whenEmailBelongsToSameUser() {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
AppUser user = AppUser.builder().id(id).username("admin").email("me@example.com").build();
|
||||||
|
when(userRepository.findById(id)).thenReturn(Optional.of(user));
|
||||||
|
when(userRepository.findByEmail("me@example.com")).thenReturn(Optional.of(user)); // same user
|
||||||
|
when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
AdminUpdateUserRequest dto = new AdminUpdateUserRequest();
|
||||||
|
dto.setEmail("me@example.com");
|
||||||
|
|
||||||
|
// Must not throw
|
||||||
|
AppUser result = userService.adminUpdateUser(id, dto);
|
||||||
|
assertThat(result.getEmail()).isEqualTo("me@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createUserOrUpdate — null groupIds ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserOrUpdate_doesNotLoadGroups_whenGroupIdsIsNull() {
|
||||||
|
// request.getGroupIds() == null → short-circuit (A=false), groupRepository never called
|
||||||
|
CreateUserRequest req = new CreateUserRequest();
|
||||||
|
req.setUsername("nullgroups");
|
||||||
|
req.setEmail("ng@example.com");
|
||||||
|
req.setInitialPassword("pass");
|
||||||
|
req.setGroupIds(null); // null → first condition false → short-circuit
|
||||||
|
|
||||||
|
when(userRepository.findByUsername("nullgroups")).thenReturn(Optional.empty());
|
||||||
|
when(passwordEncoder.encode("pass")).thenReturn("encoded");
|
||||||
|
AppUser saved = AppUser.builder().id(UUID.randomUUID()).username("nullgroups").build();
|
||||||
|
when(userRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
userService.createUserOrUpdate(req);
|
||||||
|
|
||||||
|
verify(groupRepository, never()).findAllById(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── createGroup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createGroup_createsGroupWithNameAndPermissions() {
|
||||||
|
org.raddatz.familienarchiv.dto.GroupDTO dto = new org.raddatz.familienarchiv.dto.GroupDTO();
|
||||||
|
dto.setName("Familie");
|
||||||
|
dto.setPermissions(Set.of("READ_ALL", "WRITE_ALL"));
|
||||||
|
|
||||||
|
UserGroup saved = UserGroup.builder().id(UUID.randomUUID()).name("Familie")
|
||||||
|
.permissions(Set.of("READ_ALL", "WRITE_ALL")).build();
|
||||||
|
when(groupRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
UserGroup result = userService.createGroup(dto);
|
||||||
|
|
||||||
|
assertThat(result.getName()).isEqualTo("Familie");
|
||||||
|
assertThat(result.getPermissions()).containsExactlyInAnyOrder("READ_ALL", "WRITE_ALL");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
backend/src/test/resources/application-test.yaml
Normal file
15
backend/src/test/resources/application-test.yaml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
app:
|
||||||
|
s3:
|
||||||
|
endpoint: http://localhost:9000
|
||||||
|
access-key: dummy
|
||||||
|
secret-key: dummy
|
||||||
|
bucket: test-bucket
|
||||||
|
region: us-east-1
|
||||||
|
|
||||||
|
spring:
|
||||||
|
datasource:
|
||||||
|
url: will-be-overridden-by-testcontainers
|
||||||
|
username: test
|
||||||
|
password: test
|
||||||
|
mail:
|
||||||
|
host: localhost
|
||||||
1938
docs/specs/admin-redesign-concept-c.html
Normal file
1938
docs/specs/admin-redesign-concept-c.html
Normal file
File diff suppressed because it is too large
Load Diff
963
docs/specs/annotation-transcription-final-spec.html
Normal file
963
docs/specs/annotation-transcription-final-spec.html
Normal file
@@ -0,0 +1,963 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Annotation-Backed Transcription — Final Spec</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--purple-tint:#EEEDFE;--purple:#534AB7;--purple-dark:#3C3489;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--yellow-tint:#FDF6D8;--yellow-text:#8A6800;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||||
|
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||||
|
|
||||||
|
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||||
|
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||||
|
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||||
|
.pill-g{background:var(--green-tint);color:var(--green-dark);}
|
||||||
|
|
||||||
|
.section{margin-bottom:64px;}
|
||||||
|
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||||
|
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||||
|
|
||||||
|
.jh{padding:20px 24px;border-radius:var(--radius-xl);margin-bottom:40px;display:flex;align-items:center;gap:16px;}
|
||||||
|
.jh .jn{font-family:var(--font-display);font-size:48px;font-weight:300;line-height:1;opacity:.5;}
|
||||||
|
.jh h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.jh p{font-size:13px;line-height:1.5;}.jh .fl{font-family:var(--font-mono);font-size:11px;margin-top:6px;opacity:.7;}
|
||||||
|
.jh-b{background:var(--blue-tint);border:1px solid #A4CFF4;}.jh-b .jn{color:var(--blue);}.jh-b p,.jh-b .fl{color:var(--blue-dark);}
|
||||||
|
.jh-g{background:var(--green-tint);border:1px solid #A0D8A8;}.jh-g .jn{color:var(--green);}.jh-g p,.jh-g .fl{color:var(--green-dark);}
|
||||||
|
.jh-o{background:var(--orange-tint);border:1px solid #F0C89A;}.jh-o .jn{color:var(--orange);}.jh-o p,.jh-o .fl{color:var(--orange-dark);}
|
||||||
|
|
||||||
|
.scr{margin-bottom:56px;}
|
||||||
|
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||||
|
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||||
|
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||||
|
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||||
|
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||||
|
|
||||||
|
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||||
|
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||||
|
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||||
|
|
||||||
|
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
|
||||||
|
|
||||||
|
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||||
|
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||||
|
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||||
|
|
||||||
|
/* ── FA chrome ── */
|
||||||
|
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||||
|
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||||
|
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
|
||||||
|
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||||
|
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||||
|
|
||||||
|
.fa-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;flex-shrink:0;}
|
||||||
|
.fa-topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
|
||||||
|
.fa-topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
|
||||||
|
.fa-chip .av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
|
||||||
|
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
|
||||||
|
.fa-chip .av.purple{background:#5A3080;color:#fff;}
|
||||||
|
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;}
|
||||||
|
.fa-topbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||||
|
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||||
|
.fa-topbar-btn.transcribe{background:var(--turquoise);color:var(--navy);border-color:var(--turquoise);font-weight:700;}
|
||||||
|
|
||||||
|
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;white-space:nowrap;}
|
||||||
|
|
||||||
|
/* ── PDF + paper ── */
|
||||||
|
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
|
||||||
|
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
|
||||||
|
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
|
||||||
|
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
|
||||||
|
|
||||||
|
/* ── Annotation rectangles on PDF ── */
|
||||||
|
.ann-rect{position:absolute;border-radius:2px;pointer-events:auto;cursor:pointer;transition:all .15s ease;}
|
||||||
|
.ann-rect.comment{border:1.5px solid rgba(255,200,0,.6);background:rgba(255,200,0,.15);}
|
||||||
|
.ann-rect.comment:hover{background:rgba(255,200,0,.3);}
|
||||||
|
.ann-rect.trans{border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);}
|
||||||
|
.ann-rect.trans:hover{background:rgba(0,199,177,.2);}
|
||||||
|
.ann-rect.trans.active{background:rgba(0,199,177,.25);box-shadow:0 0 0 2px var(--turquoise);}
|
||||||
|
.ann-rect .ann-num{position:absolute;top:-8px;left:-8px;width:16px;height:16px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:7px;font-weight:700;color:#fff;box-shadow:0 1px 3px rgba(0,0,0,.3);}
|
||||||
|
.ann-rect.trans .ann-num{background:var(--navy);}
|
||||||
|
.ann-rect.comment .ann-num{background:var(--orange);}
|
||||||
|
.ann-rect .ann-badge{position:absolute;bottom:-8px;right:-8px;background:var(--navy);color:#fff;font-size:6px;font-weight:700;padding:1px 4px;border-radius:8px;min-width:14px;text-align:center;box-shadow:0 1px 2px rgba(0,0,0,.3);}
|
||||||
|
|
||||||
|
/* ── Split + panels ── */
|
||||||
|
.split{display:flex;flex:1;overflow:hidden;}
|
||||||
|
.split-left{flex:1;display:flex;flex-direction:column;overflow:hidden;position:relative;}
|
||||||
|
.split-right{display:flex;flex-direction:column;overflow:hidden;border-left:1px solid #e4e2d7;}
|
||||||
|
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
|
||||||
|
|
||||||
|
/* ── Transcript blocks ── */
|
||||||
|
.tblock{margin-bottom:6px;border:1px solid var(--color-border);border-radius:5px;overflow:hidden;transition:all .15s ease;}
|
||||||
|
.tblock.active{border-color:var(--turquoise);box-shadow:0 0 0 1px var(--turquoise);}
|
||||||
|
.tblock.empty{border-style:dashed;opacity:.7;}
|
||||||
|
.tblock-head{display:flex;align-items:center;gap:4px;padding:3px 8px;font-size:6px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-muted);}
|
||||||
|
.tblock-head.active-bg{background:rgba(0,199,177,.08);}
|
||||||
|
.tblock-head .num{width:14px;height:14px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;}
|
||||||
|
.tblock-body{padding:5px 8px;font-family:Georgia,serif;font-size:9px;line-height:1.65;color:var(--color-text);min-height:18px;}
|
||||||
|
.tblock-body.editing{background:var(--color-page);cursor:text;}
|
||||||
|
.tblock-body .illegible{color:var(--color-text-muted);font-style:italic;}
|
||||||
|
.tblock-footer{display:flex;align-items:center;gap:4px;padding:2px 8px;border-top:1px solid var(--color-subtle);font-size:6px;color:var(--color-text-muted);}
|
||||||
|
|
||||||
|
.trans-cursor{display:inline-block;width:1px;height:10px;background:var(--blue);animation:blink 1s infinite;margin-left:1px;}
|
||||||
|
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
|
||||||
|
|
||||||
|
/* ── Presence ── */
|
||||||
|
.presence{display:flex;align-items:center;gap:3px;font-size:7px;color:var(--color-text-muted);}
|
||||||
|
.presence-dot{width:5px;height:5px;border-radius:50%;}
|
||||||
|
.hl-blue{border-left:2px solid var(--blue);padding-left:6px;background:rgba(45,125,210,.04);}
|
||||||
|
.hl-purple{border-left:2px solid var(--purple);padding-left:6px;background:rgba(83,74,183,.04);}
|
||||||
|
|
||||||
|
/* ── Inline comment thread ── */
|
||||||
|
.inline-thread{margin:3px 8px 5px;padding:5px 8px;border-radius:4px;border-left:2px solid var(--orange);background:var(--orange-tint);font-size:8px;color:var(--color-text);}
|
||||||
|
.inline-thread .thread-head{font-size:6px;font-weight:600;color:var(--orange-dark);margin-bottom:2px;display:flex;align-items:center;gap:3px;}
|
||||||
|
.inline-thread .thread-msg{display:flex;gap:3px;align-items:flex-start;margin-bottom:2px;}
|
||||||
|
.inline-thread .thread-av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:#fff;flex-shrink:0;}
|
||||||
|
.inline-thread .thread-reply{display:flex;gap:3px;margin-top:3px;}
|
||||||
|
.inline-thread input{flex:1;font-size:7px;padding:2px 5px;border:1px solid var(--color-border);border-radius:3px;background:#fff;}
|
||||||
|
.inline-thread .resolve-btn{font-size:6px;font-weight:600;color:var(--green-dark);padding:2px 5px;cursor:pointer;}
|
||||||
|
|
||||||
|
/* ── Hint strip ── */
|
||||||
|
.hint-strip{display:flex;align-items:center;gap:6px;padding:0 12px;height:22px;border-top:1px dashed;flex-shrink:0;font-size:7px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;}
|
||||||
|
.hint-strip.trans-hint{background:rgba(0,199,177,.06);border-color:rgba(0,199,177,.3);color:var(--navy);}
|
||||||
|
.hint-strip .hint-step{display:flex;align-items:center;gap:3px;font-weight:500;color:var(--color-text-muted);text-transform:none;letter-spacing:0;}
|
||||||
|
|
||||||
|
/* ── Transcript toolbar ── */
|
||||||
|
.trans-toolbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:6px;flex-shrink:0;}
|
||||||
|
.trans-toolbar-btn{font-size:6px;font-weight:600;padding:2px 6px;border-radius:3px;border:1px solid var(--color-border);color:var(--color-text-muted);background:transparent;cursor:pointer;display:flex;align-items:center;gap:2px;}
|
||||||
|
.trans-toolbar-btn:hover{background:var(--sand);color:var(--color-text);}
|
||||||
|
.trans-toolbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||||
|
|
||||||
|
/* ── History panel (in-toolbar) ── */
|
||||||
|
.history-panel{background:var(--color-page);border:1px solid var(--color-border);border-radius:5px;margin:4px 8px;padding:6px 8px;font-size:7px;}
|
||||||
|
.history-entry{display:flex;align-items:center;gap:4px;padding:3px 0;border-bottom:1px solid var(--color-subtle);}
|
||||||
|
.history-entry:last-child{border-bottom:none;}
|
||||||
|
.history-entry .he-date{font-size:6px;color:var(--color-text-muted);min-width:40px;}
|
||||||
|
.history-entry .he-user{font-size:6px;font-weight:600;color:var(--color-text);min-width:40px;}
|
||||||
|
.history-entry .he-diff{font-size:7px;color:var(--color-text);}
|
||||||
|
.he-add{background:var(--green-tint);color:var(--green-dark);padding:0 2px;border-radius:1px;}
|
||||||
|
.he-del{background:#FEE2E2;color:#991B1B;padding:0 2px;border-radius:1px;text-decoration:line-through;}
|
||||||
|
|
||||||
|
/* ── Status bar ── */
|
||||||
|
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
|
||||||
|
.status-saved{color:var(--green-dark);}
|
||||||
|
|
||||||
|
/* ── Agent table ── */
|
||||||
|
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||||
|
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||||
|
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
|
||||||
|
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||||
|
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||||
|
|
||||||
|
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||||
|
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||||
|
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
|
||||||
|
.llm h4{font-size:12px;font-weight:600;margin:14px 0 6px;color:var(--color-text-muted);}
|
||||||
|
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||||
|
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||||
|
.llm li{margin-bottom:4px;}
|
||||||
|
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||||
|
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||||
|
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||||
|
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||||
|
.llm td{color:var(--color-text-muted);}
|
||||||
|
|
||||||
|
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>Annotation-Backed Transcription</h1>
|
||||||
|
<p>Final spec for the collaborative inline transcription system. Draw turquoise rectangles on the scanned letter → numbered transcript blocks appear in a side panel → type what you read. Block-level comment threads with quoted selections for discussion. History in the transcript toolbar. No bottom panel.</p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
Familienarchiv<br/>
|
||||||
|
<span class="pill pill-g">Final spec</span><br/>
|
||||||
|
2026-04-04 · @leonievoss
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══ CORE CONCEPT ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Core concept — Draw-to-Transcribe</div>
|
||||||
|
<p class="prose">Today, annotations are rectangles on the PDF that open a comment thread in the side panel. By adding a <code>type</code> field to <code>DocumentAnnotation</code>, the same draw-a-rectangle gesture can create a <strong>transcription annotation</strong> (turquoise). A transcription annotation links a PDF region to an editable text block in the right panel.</p>
|
||||||
|
<p class="prose">Comments live <strong>inside transcript blocks</strong> as block-level threads. Users can select a word or phrase before commenting — the selection is <strong>auto-quoted</strong> into the comment message (e.g. <code>> “Breslau”</code>) rather than structurally anchored to character offsets. This avoids fragile offset tracking that breaks when text is edited. The quote is a display hint, not a structural anchor. Yellow comment annotations are <strong>disabled in transcribe mode</strong> — only turquoise transcription rectangles appear on the PDF.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jh jh-b">
|
||||||
|
<div class="jn">T</div>
|
||||||
|
<div><h2>Draw-to-transcribe workflow</h2><p>Draw a rectangle around a passage on the scan. A transcript block appears in the editor, linked to that region. Type what you read. Rinse and repeat down the page. Others can join and work on different blocks simultaneously.</p><div class="fl">Reuses: AnnotationLayer + PdfViewer + CommentThread · New: TranscriptBlock + TranscriptEditor + type:transcription</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══ WHAT STAYS / CHANGES / NEW ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">What stays, what changes, what’s new</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px;font-size:12px;line-height:1.6;">
|
||||||
|
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||||
|
<div style="font-weight:600;color:var(--navy);margin-bottom:6px;">Reused as-is</div>
|
||||||
|
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||||
|
<li><code>AnnotationLayer</code> — draw rects on PDF</li>
|
||||||
|
<li><code>PdfViewer</code> — render, zoom, page nav</li>
|
||||||
|
<li><code>CommentThread</code> — threaded replies, mentions</li>
|
||||||
|
<li><code>DocumentAnnotation</code> model — add <code>type</code> field</li>
|
||||||
|
<li><code>DocumentComment</code> model — unchanged</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||||
|
<div style="font-weight:600;color:var(--orange);margin-bottom:6px;">Repurposed</div>
|
||||||
|
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||||
|
<li><code>AnnotationSidePanel</code> slot → becomes the transcript editor panel</li>
|
||||||
|
<li><code>annotateMode</code> state → split into <code>annotateMode</code> + <code>transcribeMode</code></li>
|
||||||
|
<li>Annotation color → turquoise only in transcribe mode, yellow only in annotate mode (mutually exclusive)</li>
|
||||||
|
<li><code>AnnotateHintStrip</code> → new copy for transcribe mode</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-lg);padding:16px;">
|
||||||
|
<div style="font-weight:600;color:var(--green);margin-bottom:6px;">New</div>
|
||||||
|
<ul style="padding-left:16px;color:var(--color-text-muted);">
|
||||||
|
<li><code>transcription_blocks</code> table</li>
|
||||||
|
<li>Transcript editor component (right panel)</li>
|
||||||
|
<li>Block-level comment threads (quoted selections)</li>
|
||||||
|
<li><code>type</code> column on <code>document_annotations</code></li>
|
||||||
|
<li>History in transcript toolbar</li>
|
||||||
|
<li>Bottom panel removed (all modes)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
SCREEN 1 — DESKTOP TRANSCRIBE MODE
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="desktop">
|
||||||
|
<div class="scr-head"><h3>Desktop — transcribe mode active</h3><span class="scr-id">S1</span></div>
|
||||||
|
<div class="scr-desc">Two users are collaborating. <strong>Only turquoise</strong> transcription rectangles appear on the PDF — no yellow comment annotations in transcribe mode. One user (Oma Inge, purple) is editing Block 2. The current user (blue) is editing Block 3. Block 2 has a comment thread where Oma Inge quoted “Breslau” to discuss the reading. Each block has a “Kommentieren” button in its footer. The transcript toolbar shows “Verlauf” (history). No bottom panel.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="fa-link">Dokumente</div>
|
||||||
|
<div class="fa-link">Personen</div>
|
||||||
|
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="fa-topbar">
|
||||||
|
<div class="back">←</div>
|
||||||
|
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--blue);"></div> Du</div>
|
||||||
|
<div class="presence" style="margin-right:4px;"><div class="presence-dot" style="background:var(--purple);"></div> Oma Inge</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="details-toggle">Details ▼</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||||
|
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hint strip -->
|
||||||
|
<div class="hint-strip trans-hint">
|
||||||
|
<span>Transkribieren</span>
|
||||||
|
<span class="hint-step">— Markiere eine Textpassage im Scan, um einen Transkriptions-Block anzulegen</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split" style="height:400px;">
|
||||||
|
<!-- PDF with annotation rectangles -->
|
||||||
|
<div class="split-left">
|
||||||
|
<div class="pdf-area" style="flex:1;">
|
||||||
|
<div class="paper" style="width:55%;min-height:240px;position:relative;">
|
||||||
|
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||||
|
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||||
|
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||||
|
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||||
|
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||||
|
|
||||||
|
<!-- Transcription annotations (turquoise) -->
|
||||||
|
<div class="ann-rect trans" style="left:2%;top:0%;width:50%;height:10%;">
|
||||||
|
<div class="ann-num">1</div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-rect trans active" style="left:2%;top:14%;width:96%;height:32%;">
|
||||||
|
<div class="ann-num">2</div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-rect trans" style="left:2%;top:50%;width:96%;height:22%;">
|
||||||
|
<div class="ann-num">3</div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-rect trans" style="left:20%;top:80%;width:60%;height:12%;">
|
||||||
|
<div class="ann-num">4</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No yellow comment annotations in transcribe mode —
|
||||||
|
only turquoise transcription rects on the PDF -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split-handle"></div>
|
||||||
|
|
||||||
|
<!-- Transcript editor panel -->
|
||||||
|
<div class="split-right" style="width:380px;">
|
||||||
|
<!-- Transcript toolbar -->
|
||||||
|
<div class="trans-toolbar">
|
||||||
|
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<div class="trans-toolbar-btn">☰ Sortieren</div>
|
||||||
|
<div class="trans-toolbar-btn">🕑 Verlauf</div>
|
||||||
|
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block list -->
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||||
|
|
||||||
|
<!-- Block 1 — Greeting (done) -->
|
||||||
|
<div class="tblock">
|
||||||
|
<div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||||
|
<div class="tblock-body">Liebe Martha,</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block 2 — Main body (edited by Oma Inge) -->
|
||||||
|
<div class="tblock active">
|
||||||
|
<div class="tblock-head active-bg">
|
||||||
|
<div class="num">2</div> Hauptteil
|
||||||
|
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--purple);width:4px;height:4px;"></div> Oma Inge</div>
|
||||||
|
</div>
|
||||||
|
<div class="tblock-body editing hl-purple">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||||
|
|
||||||
|
<!-- Block-level thread with quoted selection -->
|
||||||
|
<div class="inline-thread">
|
||||||
|
<div class="thread-head">💬 2 Kommentare</div>
|
||||||
|
<div class="thread-msg">
|
||||||
|
<div class="thread-av" style="background:var(--purple);">OI</div>
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:7px;">Oma Inge</strong> · <span style="font-size:7px;color:var(--color-text-muted);">vor 12 Min.</span>
|
||||||
|
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin:2px 0;font-size:7px;font-style:italic;color:var(--color-text-muted);">“Breslau”</div>
|
||||||
|
<div>Ich bin sicher, das ist “Breslau” — Heinrich war dort im Lazarett.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="thread-msg">
|
||||||
|
<div class="thread-av" style="background:var(--blue);">DU</div>
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:7px;">Du</strong> · <span style="font-size:7px;color:var(--color-text-muted);">vor 8 Min.</span>
|
||||||
|
<div>Stimmt, danke! Lass ich so.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="thread-reply">
|
||||||
|
<input placeholder="Antworten..."/>
|
||||||
|
<div class="resolve-btn">✓ Lösen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block footer with comment button -->
|
||||||
|
<div class="tblock-footer">
|
||||||
|
<span style="cursor:pointer;color:var(--orange);display:flex;align-items:center;gap:2px;">💬 Kommentieren</span>
|
||||||
|
<span style="margin-left:auto;font-size:5px;color:var(--color-text-muted);">Text markieren für Zitat</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block 3 — Family (edited by current user) -->
|
||||||
|
<div class="tblock active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
|
||||||
|
<div class="tblock-head" style="background:rgba(45,125,210,.06);">
|
||||||
|
<div class="num">3</div> Familie
|
||||||
|
<div class="presence" style="margin-left:auto;"><div class="presence-dot" style="background:var(--blue);width:4px;height:4px;"></div> Du</div>
|
||||||
|
</div>
|
||||||
|
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen.<span class="trans-cursor"></span></div>
|
||||||
|
<div class="tblock-footer">
|
||||||
|
<span style="cursor:pointer;color:var(--color-text-muted);display:flex;align-items:center;gap:2px;">💬 Kommentieren</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block 4 — Closing (done) -->
|
||||||
|
<div class="tblock">
|
||||||
|
<div class="tblock-head"><div class="num">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||||
|
<div class="tblock-body">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add block CTA -->
|
||||||
|
<div class="tblock empty" style="text-align:center;padding:8px;font-size:7px;color:var(--color-text-muted);cursor:pointer;">
|
||||||
|
Markiere eine weitere Passage im Scan, um Block 5 anzulegen
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-bar">
|
||||||
|
<span>Block 3 aktiv</span>
|
||||||
|
<span>Oma Inge · Block 2</span>
|
||||||
|
<span style="margin-left:auto;">1 offene Diskussion</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- NO bottom panel in transcribe mode -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
SCREEN 2 — COMMENT FLOW: SELECT → QUOTE → DISCUSS
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="comment-flow">
|
||||||
|
<div class="scr-head"><h3>Comment flow — select, quote, discuss</h3><span class="scr-id">S2</span></div>
|
||||||
|
<div class="scr-desc">The user has selected “[unleserlich]” in Block 2 and clicked “Kommentieren”. The comment input opens with the selection auto-quoted. After posting, the comment appears in the thread with the quote displayed as an indented blockquote. This shows the full lifecycle: selection → quoted input → posted comment.</div>
|
||||||
|
<div class="scr-var"><strong>Block-level threads + quoted selections</strong> — no char-offset anchoring, no fragile highlights. The quote is frozen text in the message body.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Desktop · comment input open with auto-quote</div>
|
||||||
|
<div class="desk" style="min-height:380px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="fa-link">Dokumente</div>
|
||||||
|
<div class="fa-link">Personen</div>
|
||||||
|
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="fa-topbar">
|
||||||
|
<div class="back">←</div>
|
||||||
|
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="details-toggle">Details ▼</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||||
|
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split" style="height:290px;">
|
||||||
|
<div class="split-left">
|
||||||
|
<div class="pdf-area" style="flex:1;">
|
||||||
|
<div class="paper" style="width:55%;min-height:160px;position:relative;">
|
||||||
|
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||||
|
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||||
|
<div class="ann-rect trans active" style="left:2%;top:14%;width:96%;height:40%;"><div class="ann-num">2</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="split-handle"></div>
|
||||||
|
|
||||||
|
<div class="split-right" style="width:380px;">
|
||||||
|
<div class="trans-toolbar">
|
||||||
|
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<!-- Block 2 with text selection + open comment input -->
|
||||||
|
<div class="tblock active">
|
||||||
|
<div class="tblock-head active-bg"><div class="num">2</div> Hauptteil</div>
|
||||||
|
<div class="tblock-body editing">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen. Der Arzt sagt <span style="background:rgba(45,125,210,.25);border-radius:1px;padding:0 1px;">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||||
|
|
||||||
|
<!-- Comment input — open, with auto-quoted selection -->
|
||||||
|
<div style="margin:0 8px 5px;padding:6px 8px;border-radius:4px;border:1px solid var(--orange);background:#fff;">
|
||||||
|
<div style="font-size:6px;font-weight:600;color:var(--orange-dark);margin-bottom:3px;">Neuer Kommentar zu Block 2</div>
|
||||||
|
<!-- Auto-quoted selection shown as editable blockquote -->
|
||||||
|
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin-bottom:3px;font-size:7px;font-style:italic;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;">
|
||||||
|
> “[unleserlich]”
|
||||||
|
<span style="font-size:5px;color:var(--color-text-muted);font-style:normal;cursor:pointer;margin-left:auto;">✕ Zitat entfernen</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:3px;">
|
||||||
|
<input style="flex:1;font-size:7px;padding:3px 5px;border:1px solid var(--color-border);border-radius:3px;background:var(--color-page);" value='Könnte "sechs" oder "acht" sein. Wer hat die Originale?'/>
|
||||||
|
<button style="font-size:6px;font-weight:600;padding:3px 8px;border-radius:3px;background:var(--navy);color:#fff;border:none;cursor:pointer;">Senden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tblock-footer">
|
||||||
|
<span style="cursor:pointer;color:var(--orange);display:flex;align-items:center;gap:2px;font-weight:600;">💬 Kommentieren</span>
|
||||||
|
<span style="margin-left:auto;font-size:5px;color:var(--color-text-muted);">Text markieren für Zitat</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Block 3 — with an existing posted comment showing the quote -->
|
||||||
|
<div class="tblock">
|
||||||
|
<div class="tblock-head"><div class="num">3</div> Familie</div>
|
||||||
|
<div class="tblock-body">Die Kinder sollen wissen, dass ich an sie denke.</div>
|
||||||
|
|
||||||
|
<!-- Posted comment thread with quoted selection -->
|
||||||
|
<div class="inline-thread">
|
||||||
|
<div class="thread-head">💬 1 Kommentar</div>
|
||||||
|
<div class="thread-msg">
|
||||||
|
<div class="thread-av" style="background:var(--purple);">OI</div>
|
||||||
|
<div>
|
||||||
|
<strong style="font-size:7px;">Oma Inge</strong> · <span style="font-size:7px;color:var(--color-text-muted);">vor 5 Min.</span>
|
||||||
|
<div style="border-left:2px solid var(--color-border);padding-left:4px;margin:2px 0;font-size:7px;font-style:italic;color:var(--color-text-muted);">“Die Kinder”</div>
|
||||||
|
<div>Fritz und Lotte. Fritz war damals 4, Lotte 7.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="thread-reply">
|
||||||
|
<input placeholder="Antworten..."/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tblock-footer">
|
||||||
|
<span style="cursor:pointer;color:var(--color-text-muted);display:flex;align-items:center;gap:2px;">💬 Kommentieren</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-bar"><span>Block 2 aktiv</span><span style="margin-left:auto;">2 Kommentare</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>Comment flow · Select → Quote → Discuss</h4>
|
||||||
|
<pre>/* Comment flow for block-level threads with quoted selections:
|
||||||
|
*
|
||||||
|
* 1. TRIGGER: user clicks "Kommentieren" in block footer.
|
||||||
|
* Alternatively: Ctrl+Shift+K when block is focused.
|
||||||
|
*
|
||||||
|
* 2. AUTO-QUOTE: if text is selected in the block body (via mouse or keyboard),
|
||||||
|
* the selection is captured and pre-filled as a blockquote in the comment input:
|
||||||
|
* > "[unleserlich]"
|
||||||
|
* The user can edit or remove the quote before sending (× button).
|
||||||
|
* If no text was selected → input opens empty (general block comment).
|
||||||
|
*
|
||||||
|
* 3. STORAGE: the quote is stored as part of the comment `content` field.
|
||||||
|
* Markdown blockquote syntax: "> \"Breslau\"\nI think this is Breslau."
|
||||||
|
* The block_id FK on DocumentComment links the comment to its block.
|
||||||
|
* NO char_offset_start/end columns. The quote is just text.
|
||||||
|
*
|
||||||
|
* 4. DISPLAY: the quote renders as an indented italic line with a left border,
|
||||||
|
* above the comment text. It's visually distinct but structurally just content.
|
||||||
|
*
|
||||||
|
* 5. RESILIENCE: if the transcription text changes after quoting, nothing breaks.
|
||||||
|
* The quote is a frozen snapshot. The discussion context is preserved.
|
||||||
|
* Compare to char-offset anchoring where an edit would shift all offsets
|
||||||
|
* and potentially point to the wrong text.
|
||||||
|
*
|
||||||
|
* 6. THREAD: replies to a quoted comment don't need their own quotes —
|
||||||
|
* the parent comment provides context. Standard CommentThread reply flow.
|
||||||
|
*
|
||||||
|
* 7. MOBILE: "Kommentieren" button always visible in footer.
|
||||||
|
* Selecting text → auto-quote works the same via touch selection.
|
||||||
|
* Thread collapsed to "N Kommentare" row, tap to expand. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Comment input</td></tr>
|
||||||
|
<tr><td>Container</td><td>border:orange, bg:white, radius:4px, mx:8px</td><td>Appears below block body, above footer</td></tr>
|
||||||
|
<tr><td>Quote display</td><td>left-border:2px line, italic, 7px muted</td><td>Editable — user can modify or remove</td></tr>
|
||||||
|
<tr><td>Remove quote</td><td>"× Zitat entfernen" link, 5px, top-right of quote</td><td>Converts to general block comment</td></tr>
|
||||||
|
<tr><td>Input field</td><td>flex:1, 7px, border:line, bg:page, radius:3px</td><td>Auto-focuses when opened</td></tr>
|
||||||
|
<tr><td>Send button</td><td>"Senden", 6px/600, navy bg, white text</td><td>Enter to send, Shift+Enter for newline</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Posted comment with quote</td></tr>
|
||||||
|
<tr><td>Quote in thread</td><td>left-border:2px line, italic, 7px muted</td><td>Read-only — frozen snapshot of selected text</td></tr>
|
||||||
|
<tr><td>Message below</td><td>8px normal text, below the quote</td><td>Standard CommentThread message styling</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Data model</td></tr>
|
||||||
|
<tr><td>block_id</td><td>UUID FK → transcription_blocks (nullable)</td><td>Links comment to its block</td></tr>
|
||||||
|
<tr><td>content</td><td>TEXT with markdown blockquote</td><td>> "quoted text"\nComment message</td></tr>
|
||||||
|
<tr><td>No char offsets</td><td>—</td><td>Intentional. See spec rationale.</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
SCREEN 3 — HISTORY IN TRANSCRIPT TOOLBAR
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="history">
|
||||||
|
<div class="scr-head"><h3>Desktop — history panel open</h3><span class="scr-id">S3</span></div>
|
||||||
|
<div class="scr-desc">Clicking “Verlauf” in the transcript toolbar opens a collapsible history panel between the toolbar and the block list. Shows recent changes with word-level diffs, just like the existing <code>PanelHistory</code> component but embedded in the transcript panel instead of the bottom panel.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px · history open</div>
|
||||||
|
<div class="desk" style="min-height:540px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="fa-link">Dokumente</div>
|
||||||
|
<div class="fa-link">Personen</div>
|
||||||
|
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="fa-topbar">
|
||||||
|
<div class="back">←</div>
|
||||||
|
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="details-toggle">Details ▼</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||||
|
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hint-strip trans-hint">
|
||||||
|
<span>Transkribieren</span>
|
||||||
|
<span class="hint-step">— Markiere eine Textpassage im Scan</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split" style="height:420px;">
|
||||||
|
<div class="split-left">
|
||||||
|
<div class="pdf-area" style="flex:1;">
|
||||||
|
<div class="paper" style="width:55%;min-height:220px;position:relative;">
|
||||||
|
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||||
|
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||||
|
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
|
||||||
|
<div class="ann-rect trans" style="left:2%;top:0%;width:50%;height:10%;"><div class="ann-num">1</div></div>
|
||||||
|
<div class="ann-rect trans" style="left:2%;top:14%;width:96%;height:32%;"><div class="ann-num">2</div></div>
|
||||||
|
<div class="ann-rect trans" style="left:2%;top:50%;width:96%;height:22%;"><div class="ann-num">3</div></div>
|
||||||
|
<div class="ann-rect trans" style="left:20%;top:80%;width:60%;height:12%;"><div class="ann-num">4</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split-handle"></div>
|
||||||
|
|
||||||
|
<div class="split-right" style="width:380px;">
|
||||||
|
<!-- Toolbar with history active -->
|
||||||
|
<div class="trans-toolbar">
|
||||||
|
<span style="font-size:7px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<div class="trans-toolbar-btn">☰ Sortieren</div>
|
||||||
|
<div class="trans-toolbar-btn active">🕑 Verlauf</div>
|
||||||
|
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History panel (collapsible) -->
|
||||||
|
<div class="history-panel">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
|
||||||
|
<span style="font-size:6px;font-weight:600;color:var(--navy);text-transform:uppercase;letter-spacing:.06em;">Letzte Änderungen</span>
|
||||||
|
<span style="font-size:6px;color:var(--color-text-muted);cursor:pointer;">Alle anzeigen →</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-entry">
|
||||||
|
<span class="he-date">14:23</span>
|
||||||
|
<span class="he-user">Oma Inge</span>
|
||||||
|
<span class="he-diff">Block 2: ...Lazarett in <span class="he-add">Breslau</span><span class="he-del">Bresla</span>...</span>
|
||||||
|
</div>
|
||||||
|
<div class="history-entry">
|
||||||
|
<span class="he-date">14:18</span>
|
||||||
|
<span class="he-user">Du</span>
|
||||||
|
<span class="he-diff">Block 3: <span class="he-add">Die Kinder sollen wissen, dass ich an sie denke.</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="history-entry">
|
||||||
|
<span class="he-date">14:12</span>
|
||||||
|
<span class="he-user">Oma Inge</span>
|
||||||
|
<span class="he-diff">Block 2: <span class="he-add">ich schreibe Dir heute aus dem Lazarett</span></span>
|
||||||
|
</div>
|
||||||
|
<div class="history-entry">
|
||||||
|
<span class="he-date">14:05</span>
|
||||||
|
<span class="he-user">Du</span>
|
||||||
|
<span class="he-diff">Block 1: <span class="he-add">Liebe Martha,</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocks below history -->
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div class="tblock">
|
||||||
|
<div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||||
|
<div class="tblock-body">Liebe Martha,</div>
|
||||||
|
</div>
|
||||||
|
<div class="tblock">
|
||||||
|
<div class="tblock-head"><div class="num">2</div> Hauptteil</div>
|
||||||
|
<div class="tblock-body">ich schreibe Dir heute aus dem Lazarett in Breslau...</div>
|
||||||
|
</div>
|
||||||
|
<div class="tblock active" style="border-color:var(--blue);box-shadow:0 0 0 1px var(--blue);">
|
||||||
|
<div class="tblock-head" style="background:rgba(45,125,210,.06);"><div class="num">3</div> Familie</div>
|
||||||
|
<div class="tblock-body editing hl-blue">Die Kinder sollen wissen...<span class="trans-cursor"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="tblock">
|
||||||
|
<div class="tblock-head"><div class="num">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||||
|
<div class="tblock-body">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-bar"><span>Block 3 aktiv</span><span style="margin-left:auto;">✓ Gespeichert</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
SCREEN 3 — MOBILE TRANSCRIBE MODE
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="mobile">
|
||||||
|
<div class="scr-head"><h3>Mobile — transcribe mode</h3><span class="scr-id">S4</span></div>
|
||||||
|
<div class="scr-desc">On mobile, the PDF collapses to a 90px strip at the top. Annotation rectangles are visible as thin outlines. Transcript blocks stack vertically below. The history button is in the toolbar above the blocks. Inline threads expand in-place.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px</div>
|
||||||
|
<div class="phone" style="height:600px;">
|
||||||
|
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||||
|
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||||
|
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
|
||||||
|
</div>
|
||||||
|
<!-- PDF strip with annotations -->
|
||||||
|
<div style="background:#D4D0C8;height:90px;display:flex;align-items:center;justify-content:center;position:relative;border-bottom:2px solid var(--turquoise);">
|
||||||
|
<div style="background:#FFFEF8;width:45%;padding:6px 8px;box-shadow:0 1px 4px rgba(0,0,0,.12);border-radius:1px;position:relative;">
|
||||||
|
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||||
|
<div style="height:1.5px;background:#C4BDB0;opacity:.25;margin:1px 0;width:90%;"></div>
|
||||||
|
<div style="height:1.5px;background:#C4BDB0;opacity:.25;margin:1px 0;width:70%;"></div>
|
||||||
|
<div style="position:absolute;left:2%;top:0;width:50%;height:18%;border:1px solid var(--turquoise);border-radius:1px;opacity:.5;"></div>
|
||||||
|
<div style="position:absolute;left:2%;top:22%;width:96%;height:35%;border:1px solid var(--turquoise);border-radius:1px;background:rgba(0,199,177,.1);"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 12px;gap:4px;">
|
||||||
|
<span style="font-size:8px;font-weight:600;color:var(--navy);">4 Blöcke</span>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<div style="font-size:7px;font-weight:600;padding:3px 6px;border-radius:3px;border:1px solid var(--color-border);color:var(--color-text-muted);">🕑 Verlauf</div>
|
||||||
|
<span style="font-size:7px;color:var(--green-dark);">✓</span>
|
||||||
|
</div>
|
||||||
|
<!-- Block list -->
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||||
|
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||||
|
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||||
|
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
|
||||||
|
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil <span style="font-size:5px;color:var(--purple);margin-left:auto;">Oma Inge</span></div>
|
||||||
|
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;border-left:2px solid var(--purple);">ich schreibe Dir heute aus dem Lazarett in Breslau...</div>
|
||||||
|
<!-- Inline thread (collapsed on mobile — tap to expand) -->
|
||||||
|
<div style="padding:3px 8px;border-top:1px solid var(--color-subtle);display:flex;align-items:center;gap:3px;">
|
||||||
|
<span style="font-size:7px;color:var(--orange);">💬</span>
|
||||||
|
<span style="font-size:7px;color:var(--color-text-muted);">1 Diskussion · “Breslau”</span>
|
||||||
|
<span style="font-size:7px;color:var(--color-text-muted);margin-left:auto;">▼</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid var(--blue);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--blue);">
|
||||||
|
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(45,125,210,.06);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">3</div> Familie <span style="font-size:5px;color:var(--blue);margin-left:auto;">Du</span></div>
|
||||||
|
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;border-left:2px solid var(--blue);">Die Kinder sollen wissen...<span class="trans-cursor"></span></div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||||
|
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">4</div> Schluss <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||||
|
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══ AGENT TABLES ═══ -->
|
||||||
|
<div class="agent">
|
||||||
|
<h4>Annotation-backed transcription · Core implementation spec</h4>
|
||||||
|
<pre>/* Core flow: enter transcribe mode → crosshair cursor on PDF → draw rect → creates:
|
||||||
|
* 1. DocumentAnnotation(type:"transcription", turquoise) in the DB
|
||||||
|
* 2. TranscriptionBlock(annotation_id, text:"", sort_order:N) in the DB
|
||||||
|
* 3. Editable block in the right panel, linked to the annotation
|
||||||
|
* Clicking an annotation rect on PDF scrolls to + highlights the matching block.
|
||||||
|
* Clicking a block header highlights the matching rect on PDF.
|
||||||
|
*
|
||||||
|
* COMMENTS: block-level threads with quoted selections.
|
||||||
|
* - Each block has a "Kommentieren" button in its footer.
|
||||||
|
* - If text is selected when clicking "Kommentieren", the selection is auto-quoted
|
||||||
|
* into the comment (> "Breslau"). The quote is plain text in the message body,
|
||||||
|
* NOT a structural char-offset anchor. It doesn't break when text changes.
|
||||||
|
* - Threads are anchored to block_id only (no char offsets).
|
||||||
|
* - Yellow comment annotations are DISABLED in transcribe mode.
|
||||||
|
* Only turquoise transcription rects on the PDF. One annotation type per mode.
|
||||||
|
*
|
||||||
|
* History: "Verlauf" button in transcript toolbar toggles a collapsible panel
|
||||||
|
* showing recent changes with word-level diffs per block.
|
||||||
|
* Auto-save: debounced PATCH to /api/transcription-blocks/{blockId} (500ms).
|
||||||
|
* Bottom panel: removed entirely (all modes). Metadata → topbar drawer. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Annotation reuse</td></tr>
|
||||||
|
<tr><td>Draw gesture</td><td>Existing AnnotationLayer.onDraw(rect)</td><td>Same pointer events. crosshair cursor.</td></tr>
|
||||||
|
<tr><td>Annotation color</td><td>turquoise (#00C7B1) for transcription</td><td>Yellow annotations disabled in transcribe mode</td></tr>
|
||||||
|
<tr><td>Annotation type</td><td>New column: type VARCHAR "transcription"|"comment"</td><td>Default "comment" for backward compat</td></tr>
|
||||||
|
<tr><td>Number badge</td><td>16px navy circle, top-left of rect</td><td>Sort order number, matches block number</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Transcript blocks (right panel)</td></tr>
|
||||||
|
<tr><td>Block card</td><td>border:1px line, radius:5px, active: turquoise glow</td><td>Header: number + label + presence. Body: contenteditable.</td></tr>
|
||||||
|
<tr><td>Block label</td><td>Editable text, defaults: Anrede, Hauptteil, Schluss</td><td>Double-click to rename</td></tr>
|
||||||
|
<tr><td>Empty state</td><td>Dashed border, "noch leer" italic text</td><td>Focus to start typing</td></tr>
|
||||||
|
<tr><td>Add block CTA</td><td>Dashed card: "Markiere eine Passage im Scan..."</td><td>Not clickable — directs user to draw on PDF</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Block-level comment threads</td></tr>
|
||||||
|
<tr><td>Trigger</td><td>"Kommentieren" button in block footer</td><td>Always visible — no hover-reveal</td></tr>
|
||||||
|
<tr><td>Quoted selection</td><td>If text selected → auto-quoted into comment body</td><td>Plain text quote (> "Breslau"), NOT char-offset anchor</td></tr>
|
||||||
|
<tr><td>Quote display</td><td>Left border + italic, above the comment text</td><td>Decorative only — doesn't link to text range</td></tr>
|
||||||
|
<tr><td>Thread UI</td><td>orange left-border, orange-tint bg, below block body</td><td>Block-level anchor (block_id). Reuses CommentThread.</td></tr>
|
||||||
|
<tr><td>Footer hint</td><td>"Text markieren für Zitat" in 5px muted text</td><td>Only shown when block is active/focused</td></tr>
|
||||||
|
<tr><td>Resolve</td><td>"✓ Lösen" button collapses thread</td><td>Resolved threads hidden by default, toggle to show</td></tr>
|
||||||
|
<tr><td>Mobile</td><td>Threads collapsed to "2 Kommentare" row, tap to expand</td><td>Saves vertical space on small screens</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Yellow annotations in transcribe mode</td></tr>
|
||||||
|
<tr><td>Status</td><td>Disabled — draw gesture only creates turquoise rects</td><td>Existing yellow annotations still visible (read-only)</td></tr>
|
||||||
|
<tr><td>Annotate mode</td><td>Still available via topbar "Annotieren" button</td><td>Exits transcribe mode, enters annotate mode (yellow)</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">History (transcript toolbar)</td></tr>
|
||||||
|
<tr><td>Toggle</td><td>"🕗 Verlauf" button in transcript toolbar</td><td>Active state: navy bg, white text</td></tr>
|
||||||
|
<tr><td>Panel</td><td>Collapsible, between toolbar and block list</td><td>bg:color-page, border:line, radius:5px</td></tr>
|
||||||
|
<tr><td>Entries</td><td>Time + user + block ref + word-level diff</td><td>Reuses diffWords from 'diff' library</td></tr>
|
||||||
|
<tr><td>"Alle anzeigen"</td><td>Link to full history view (reuses PanelHistory)</td><td>Opens in a modal or replaces block list temporarily</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Interaction</td></tr>
|
||||||
|
<tr><td>Click rect → block</td><td>scrollIntoView + active state on block</td><td>Turquoise glow on both rect and block</td></tr>
|
||||||
|
<tr><td>Click block → rect</td><td>PDF scrolls/zooms to show the annotation</td><td>If multi-page: switches page</td></tr>
|
||||||
|
<tr><td>Delete block</td><td>Deletes annotation + block + threads</td><td>Confirm dialog if threads exist</td></tr>
|
||||||
|
<tr><td>Reorder blocks</td><td>Drag handle in block header</td><td>Updates sort_order via PATCH</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Presence (collaborative)</td></tr>
|
||||||
|
<tr><td>Dots in topbar</td><td>Colored dot + user name, flex row</td><td>Max 3 shown, "+N" overflow</td></tr>
|
||||||
|
<tr><td>Block-level presence</td><td>Colored dot + name in block header</td><td>Left border color matches user</td></tr>
|
||||||
|
<tr><td>Implementation</td><td>WebSocket presence via Y.js (future)</td><td>MVP: polling-based, 5s interval</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Auto-save</td></tr>
|
||||||
|
<tr><td>Debounce</td><td>500ms after last keystroke</td><td>PATCH /api/transcription-blocks/{blockId}</td></tr>
|
||||||
|
<tr><td>Status</td><td>"✓ Gespeichert" in toolbar, fades after 3s</td><td>"Speichern..." while request in-flight</td></tr>
|
||||||
|
<tr><td>Conflict</td><td>Last-write-wins for MVP</td><td>Y.js CRDT for future collaborative editing</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||||
|
<div class="llm">
|
||||||
|
<h2>Implementation Guide — Annotation-Backed Transcription</h2>
|
||||||
|
|
||||||
|
<h3>1. Data Model Changes</h3>
|
||||||
|
|
||||||
|
<h4>Flyway migration: <code>document_annotations</code></h4>
|
||||||
|
<ul>
|
||||||
|
<li>Add <code>type VARCHAR(20) NOT NULL DEFAULT 'comment'</code>.</li>
|
||||||
|
<li>Values: <code>'comment'</code> (existing behavior) or <code>'transcription'</code>.</li>
|
||||||
|
<li>Backward compatible — all existing annotations default to <code>'comment'</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>New table: <code>transcription_blocks</code></h4>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>id</code></td><td>UUID PK</td><td>Generated</td></tr>
|
||||||
|
<tr><td><code>annotation_id</code></td><td>UUID FK → document_annotations</td><td>Links block to its PDF rectangle</td></tr>
|
||||||
|
<tr><td><code>document_id</code></td><td>UUID FK → documents</td><td>Denormalized for efficient queries</td></tr>
|
||||||
|
<tr><td><code>text</code></td><td>TEXT</td><td>The transcription content</td></tr>
|
||||||
|
<tr><td><code>label</code></td><td>VARCHAR(100)</td><td>"Anrede", "Hauptteil", etc.</td></tr>
|
||||||
|
<tr><td><code>sort_order</code></td><td>INT</td><td>Display order in the editor</td></tr>
|
||||||
|
<tr><td><code>created_by</code></td><td>UUID FK → app_users</td><td></td></tr>
|
||||||
|
<tr><td><code>updated_by</code></td><td>UUID FK → app_users</td><td></td></tr>
|
||||||
|
<tr><td><code>created_at</code></td><td>TIMESTAMP</td><td>@CreationTimestamp</td></tr>
|
||||||
|
<tr><td><code>updated_at</code></td><td>TIMESTAMP</td><td>@UpdateTimestamp</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>Block-level comments: <code>document_comments</code></h4>
|
||||||
|
<ul>
|
||||||
|
<li>Add <code>block_id UUID FK → transcription_blocks</code> (nullable).</li>
|
||||||
|
<li><strong>No char_offset columns.</strong> Quoted selections are stored as plain text in the comment <code>content</code> field using blockquote markdown syntax (<code>> “Breslau”</code>). This is intentional — char offsets break when text is edited and require OT/CRDT to maintain. Quotes are a display hint, not a structural anchor.</li>
|
||||||
|
<li>Backward compatible — <code>block_id</code> is nullable, existing comments unaffected.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>Backward compatibility: <code>Document.transcription</code></h4>
|
||||||
|
<p>The existing <code>transcription</code> TEXT field becomes a <strong>computed read-only view</strong>: <code>SELECT string_agg(text, E'\n\n' ORDER BY sort_order) FROM transcription_blocks WHERE document_id = ?</code>. Write operations go through the block API. This keeps search indexing, export, and the read-only <code>PanelTranscription</code> working without changes.</p>
|
||||||
|
|
||||||
|
<h3>2. Annotation Color Convention & Mode Exclusivity</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Type</th><th>Color</th><th>Hex</th><th>On click</th><th>When active</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Comment</td><td>Yellow</td><td><code>#FFC800</code></td><td>Opens AnnotationSidePanel (existing)</td><td>Annotate mode only</td></tr>
|
||||||
|
<tr><td>Transcription</td><td>Turquoise</td><td><code>#00C7B1</code></td><td>Highlights matching block in transcript editor</td><td>Transcribe mode only</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p><strong>Mode exclusivity:</strong> In transcribe mode, only turquoise rects can be drawn. Existing yellow comment annotations from annotate mode are still <em>visible</em> on the PDF (read-only, dimmed) but cannot be created or interacted with. The “Annotieren” button exits transcribe mode and enters annotate mode (and vice versa). This prevents overlapping annotation types and avoids user confusion about which comment system to use.</p>
|
||||||
|
|
||||||
|
<h3>3. Component Architecture</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Component</th><th>Change</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>AnnotationLayer.svelte</code></td><td>Pass <code>type</code> to <code>onDraw</code> callback. Render turquoise vs yellow based on annotation type. Add number badges for transcription annotations.</td></tr>
|
||||||
|
<tr><td><code>PdfViewer.svelte</code></td><td>Split <code>handleAnnotationDraw</code> into two paths (annotate vs transcribe). Route <code>handleAnnotationClick</code> to either side panel or transcript editor.</td></tr>
|
||||||
|
<tr><td><code>AnnotationSidePanel.svelte</code></td><td>No change — still handles comment-type annotations in annotate mode. Hidden in transcribe mode.</td></tr>
|
||||||
|
<tr><td><code>TranscriptEditor.svelte</code> (new)</td><td>Right panel. Renders transcript toolbar + block list. Manages block CRUD, auto-save, block-level comment threads.</td></tr>
|
||||||
|
<tr><td><code>TranscriptBlock.svelte</code> (new)</td><td>Single block card. contenteditable body, header with number/label/presence, footer with “Kommentieren” button, thread slot below body.</td></tr>
|
||||||
|
<tr><td><code>BlockCommentThread.svelte</code> (new)</td><td>Comment thread anchored to a block. Shows quoted selections as blockquotes. Reuses <code>CommentThread</code> internally for replies/mentions.</td></tr>
|
||||||
|
<tr><td><code>TranscriptToolbar.svelte</code> (new)</td><td>Block count, sort button, history toggle, save status.</td></tr>
|
||||||
|
<tr><td><code>TranscriptHistory.svelte</code> (new)</td><td>Collapsible panel. Reuses <code>diffWords</code> from the <code>diff</code> library. Shows recent changes per block.</td></tr>
|
||||||
|
<tr><td><code>DocumentBottomPanel.svelte</code></td><td>Removed entirely. Metadata lives in the topbar drawer (see companion spec). Discussion, transcription, and history are all inline.</td></tr>
|
||||||
|
<tr><td><code>documents/[id]/+page.svelte</code></td><td>Add <code>transcribeMode</code> state. Conditionally render TranscriptEditor vs bottom panel.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>4. API Endpoints</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Method</th><th>Path</th><th>Notes</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>POST</td><td><code>/api/documents/{id}/annotations</code></td><td>Existing, but now accepts <code>type</code> field. If <code>type="transcription"</code>, also creates a TranscriptionBlock.</td></tr>
|
||||||
|
<tr><td>GET</td><td><code>/api/documents/{id}/transcription-blocks</code></td><td>Returns all blocks ordered by sort_order.</td></tr>
|
||||||
|
<tr><td>PATCH</td><td><code>/api/transcription-blocks/{blockId}</code></td><td>Update text, label, or sort_order. Auto-save target.</td></tr>
|
||||||
|
<tr><td>DELETE</td><td><code>/api/transcription-blocks/{blockId}</code></td><td>Deletes block + its annotation + any anchored comments.</td></tr>
|
||||||
|
<tr><td>PATCH</td><td><code>/api/transcription-blocks/reorder</code></td><td>Bulk update sort_order for drag-and-drop reordering.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>5. Draw-to-Transcribe Workflow</h3>
|
||||||
|
<ol>
|
||||||
|
<li>User enters <strong>Transcribe mode</strong> (topbar button, turquoise). Hint strip appears. Yellow comment annotations become read-only/dimmed. Only turquoise rects can be drawn.</li>
|
||||||
|
<li>Crosshair cursor on PDF (same as annotate mode). User draws a rectangle around a handwriting passage.</li>
|
||||||
|
<li><code>AnnotationLayer.onDraw(rect)</code> fires. <code>PdfViewer</code> calls <code>POST /api/documents/{id}/annotations</code> with <code>type: "transcription"</code>.</li>
|
||||||
|
<li>Backend creates <code>DocumentAnnotation</code> + <code>TranscriptionBlock</code> (empty text, next sort_order).</li>
|
||||||
|
<li>Frontend receives the created annotation + block. The transcript editor scrolls to the new empty block and focuses it.</li>
|
||||||
|
<li>User types the transcription. Auto-save debounces to <code>PATCH /api/transcription-blocks/{blockId}</code>.</li>
|
||||||
|
<li>Repeat: draw next rectangle, type next block.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>6. Comment Flow — Block-Level Threads with Quoted Selections</h3>
|
||||||
|
<p>Comments are anchored to <strong>blocks</strong>, not character offsets. This is a deliberate simplification:</p>
|
||||||
|
|
||||||
|
<h4>Why not char-offset anchoring?</h4>
|
||||||
|
<ul>
|
||||||
|
<li>When someone edits the transcription text, all character offsets downstream shift.</li>
|
||||||
|
<li>Keeping offsets in sync requires operational transforms (OT) or CRDT — that’s the Y.js future work, not MVP.</li>
|
||||||
|
<li>A stale offset pointing to the wrong word is worse than a quoted snippet that no longer matches but still shows what was discussed.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>How it works</h4>
|
||||||
|
<ol>
|
||||||
|
<li>User clicks <strong>“Kommentieren”</strong> in a block footer.</li>
|
||||||
|
<li>If text is selected in the block body, the selection is <strong>auto-quoted</strong> into the comment input: <code>> “Breslau”</code>. The user can edit or remove the quote before sending.</li>
|
||||||
|
<li>If no text is selected, the comment input opens empty — a general block-level comment.</li>
|
||||||
|
<li>The comment is saved as a <code>DocumentComment</code> with <code>block_id</code> set. The quoted text is part of the <code>content</code> field (markdown blockquote syntax).</li>
|
||||||
|
<li>The thread renders below the block body with an orange left-border. Quoted text appears as an indented italic blockquote above the comment message.</li>
|
||||||
|
<li>Replies work the same as existing <code>CommentThread</code> — no changes needed.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h4>What happens when text changes after quoting?</h4>
|
||||||
|
<p>Nothing breaks. The quote is a frozen snapshot of what the user selected. If “Bresla” was later corrected to “Breslau”, the original quote still reads <code>> “Bresla”</code> with Oma Inge’s comment “I think this is Breslau.” The context is preserved. No orphaned anchors, no broken highlights.</p>
|
||||||
|
|
||||||
|
<h4>Footer hint</h4>
|
||||||
|
<p>When a block is focused/active, the footer shows a subtle hint: <em>“Text markieren für Zitat”</em> (select text for a quote). This teaches the quoted-selection pattern without requiring documentation.</p>
|
||||||
|
|
||||||
|
<h3>7. History in Transcript Toolbar</h3>
|
||||||
|
<ul>
|
||||||
|
<li>The “Verlauf” button in the toolbar toggles <code>TranscriptHistory.svelte</code>.</li>
|
||||||
|
<li>The panel renders between the toolbar and the block list (pushes blocks down).</li>
|
||||||
|
<li>It shows recent changes per block, using <code>diffWords</code> from the <code>diff</code> library (same as existing <code>PanelHistory</code>).</li>
|
||||||
|
<li>Each entry: timestamp, user name, block reference (e.g. “Block 2”), and a word-level diff snippet.</li>
|
||||||
|
<li>“Alle anzeigen” opens a full history view — can reuse the existing <code>PanelHistory</code> component in a modal.</li>
|
||||||
|
<li>Data source: the existing document version history API, filtered/grouped by block.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>8. Accessibility</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Transcription blocks: <code>role="region"</code> with <code>aria-label="Transkriptions-Block N: [label]"</code></li>
|
||||||
|
<li>Block body: <code>contenteditable</code> with <code>aria-multiline="true"</code></li>
|
||||||
|
<li>Number badges on PDF: <code>aria-label="Transkriptions-Bereich N"</code></li>
|
||||||
|
<li>Comment button: <code>aria-label="Block N kommentieren"</code></li>
|
||||||
|
<li>History toggle: <code>aria-expanded</code>, <code>aria-controls="transcript-history"</code></li>
|
||||||
|
<li>Focus order: topbar → hint strip → PDF (for drawing) → transcript blocks (in sort order) → comment button → status bar</li>
|
||||||
|
<li>Keyboard: Tab between blocks, Enter to edit, Escape to deselect. Ctrl+Shift+N to prompt draw on PDF. Ctrl+Shift+K to open comment on focused block.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>9. Companion Spec</h3>
|
||||||
|
<p>The expandable metadata header (labeled “Details ▼” toggle) is specified separately in <code>expandable-metadata-header-spec.html</code>. Together, these two specs fully eliminate the bottom panel in <strong>all modes</strong>: metadata → header drawer, transcription → inline split view, discussion → inline threads, history → transcript toolbar. One consistent pattern — no mode-dependent UI structure.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1252
docs/specs/conversations-narrow-column.html
Normal file
1252
docs/specs/conversations-narrow-column.html
Normal file
File diff suppressed because it is too large
Load Diff
700
docs/specs/expandable-metadata-header-spec.html
Normal file
700
docs/specs/expandable-metadata-header-spec.html
Normal file
@@ -0,0 +1,700 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Expandable Metadata Header — Final Spec</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||||
|
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||||
|
|
||||||
|
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||||
|
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||||
|
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||||
|
.pill-g{background:var(--green-tint);color:var(--green-dark);}
|
||||||
|
|
||||||
|
.section{margin-bottom:64px;}
|
||||||
|
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||||
|
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||||
|
|
||||||
|
.scr{margin-bottom:56px;}
|
||||||
|
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||||
|
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||||
|
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||||
|
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||||
|
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||||
|
|
||||||
|
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||||
|
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||||
|
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||||
|
|
||||||
|
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
|
||||||
|
|
||||||
|
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||||
|
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||||
|
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||||
|
|
||||||
|
/* ── FA chrome ── */
|
||||||
|
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||||
|
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||||
|
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
|
||||||
|
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||||
|
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||||
|
|
||||||
|
/* ── Topbar ── */
|
||||||
|
.topbar{background:#fff;border-bottom:1px solid #e4e2d7;flex-shrink:0;position:relative;}
|
||||||
|
.topbar-main{display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;}
|
||||||
|
.topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
|
||||||
|
.topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.topbar .date{font-size:8px;color:var(--color-text-muted);}
|
||||||
|
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
|
||||||
|
.fa-chip .av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
|
||||||
|
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
|
||||||
|
.fa-chip .av.purple{background:#5A3080;color:#fff;}
|
||||||
|
.fa-chip a{color:inherit;text-decoration:none;}
|
||||||
|
.fa-chip a:hover{text-decoration:underline;}
|
||||||
|
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;cursor:pointer;}
|
||||||
|
.fa-topbar-btn.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||||
|
.fa-topbar-btn.transcribe{background:var(--turquoise);color:var(--navy);border-color:var(--turquoise);font-weight:700;}
|
||||||
|
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||||
|
|
||||||
|
/* ── Details toggle ── */
|
||||||
|
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;transition:all .15s ease;white-space:nowrap;}
|
||||||
|
.details-toggle:hover{background:var(--sand);color:var(--color-text);}
|
||||||
|
.details-toggle.open{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||||
|
.details-toggle .chevron-icon{display:inline-block;font-size:7px;transition:transform .2s ease;}
|
||||||
|
.details-toggle.open .chevron-icon{transform:rotate(180deg);}
|
||||||
|
|
||||||
|
/* ── PDF area ── */
|
||||||
|
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
|
||||||
|
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
|
||||||
|
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
|
||||||
|
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
|
||||||
|
|
||||||
|
/* ── Annotation rects ── */
|
||||||
|
.ann-rect{position:absolute;border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);border-radius:2px;}
|
||||||
|
.ann-num{position:absolute;top:-8px;left:-8px;width:14px;height:14px;border-radius:50%;background:var(--navy);display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;color:#fff;box-shadow:0 1px 2px rgba(0,0,0,.3);}
|
||||||
|
|
||||||
|
/* ── Transcript blocks ── */
|
||||||
|
.tblock{margin-bottom:5px;border:1px solid var(--color-border);border-radius:5px;overflow:hidden;}
|
||||||
|
.tblock.active{border-color:var(--turquoise);box-shadow:0 0 0 1px var(--turquoise);}
|
||||||
|
.tblock-head{display:flex;align-items:center;gap:4px;padding:3px 8px;font-size:6px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--color-text-muted);}
|
||||||
|
.tblock-head .num{width:14px;height:14px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:700;flex-shrink:0;}
|
||||||
|
.tblock-body{padding:5px 8px;font-family:Georgia,serif;font-size:9px;line-height:1.65;color:var(--color-text);min-height:16px;}
|
||||||
|
.trans-cursor{display:inline-block;width:1px;height:10px;background:var(--blue);animation:blink 1s infinite;margin-left:1px;}
|
||||||
|
@keyframes blink{0%,50%{opacity:1}51%,100%{opacity:0}}
|
||||||
|
|
||||||
|
.split{display:flex;flex:1;overflow:hidden;}
|
||||||
|
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
|
||||||
|
|
||||||
|
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
|
||||||
|
|
||||||
|
/* ── Metadata display elements ── */
|
||||||
|
.meta-icon{width:14px;height:14px;display:flex;align-items:center;justify-content:center;font-size:9px;opacity:.5;flex-shrink:0;}
|
||||||
|
.meta-label{font-size:5px;font-weight:600;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;}
|
||||||
|
.meta-value{font-family:Georgia,serif;font-size:8px;color:var(--color-text);}
|
||||||
|
.meta-value a{color:var(--navy);text-decoration:none;}.meta-value a:hover{text-decoration:underline;}
|
||||||
|
.tag-chip{display:inline-block;font-size:6px;font-weight:600;padding:1px 5px;border-radius:3px;background:var(--sand);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.04em;cursor:pointer;}
|
||||||
|
.tag-chip:hover{background:var(--navy);color:#fff;}
|
||||||
|
|
||||||
|
/* ── Person card (for expanded metadata) ── */
|
||||||
|
.person-card{display:flex;align-items:center;gap:5px;padding:4px 6px;border:1px solid var(--color-border);border-radius:5px;background:var(--color-page);cursor:pointer;transition:all .1s;}
|
||||||
|
.person-card:hover{border-color:var(--mint);background:var(--accent-bg);}
|
||||||
|
.person-card .pc-av{width:18px;height:18px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:800;flex-shrink:0;}
|
||||||
|
.person-card .pc-name{font-family:Georgia,serif;font-size:8px;color:var(--color-text);}
|
||||||
|
.person-card .pc-alias{font-size:6px;color:var(--color-text-muted);}
|
||||||
|
.person-card .pc-action{font-size:7px;color:var(--color-text-muted);margin-left:auto;opacity:0;transition:opacity .1s;}
|
||||||
|
.person-card:hover .pc-action{opacity:1;}
|
||||||
|
|
||||||
|
/* ── Agent table ── */
|
||||||
|
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||||
|
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||||
|
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
|
||||||
|
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||||
|
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||||
|
|
||||||
|
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||||
|
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||||
|
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
|
||||||
|
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||||
|
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||||
|
.llm li{margin-bottom:4px;}
|
||||||
|
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||||
|
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||||
|
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||||
|
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||||
|
.llm td{color:var(--color-text-muted);}
|
||||||
|
|
||||||
|
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>Expandable Metadata Header</h1>
|
||||||
|
<p>The document topbar gains a labeled toggle button (<strong>“Details ▼”</strong>) that opens a full-width metadata drawer below the main row. This replaces the bottom panel’s Metadata tab in transcribe mode, keeping all interactive elements (person links, conversation links, tag filters) accessible without consuming permanent viewport space.</p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
Familienarchiv<br/>
|
||||||
|
<span class="pill pill-g">Final spec</span><br/>
|
||||||
|
2026-04-04 · @leonievoss
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══ DESIGN DECISION ═══ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Why a labeled toggle, not just a chevron</div>
|
||||||
|
<p class="prose">User interviews include family members aged 60+. A bare 12–16px chevron icon is easy to miss or misinterpret as decorative. A labeled button — <strong>“Details ▼”</strong> — is self-explanatory, provides a larger click target (min 44×28px), and follows the progressive disclosure pattern: key facts (title, date, person chips) are always visible in the topbar; the toggle reveals the full metadata only when needed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">What lives where</div>
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;">
|
||||||
|
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:200px;">
|
||||||
|
<div style="font-weight:600;color:var(--navy);margin-bottom:4px;">Always visible in topbar</div>
|
||||||
|
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||||
|
<li>Document title (truncated)</li>
|
||||||
|
<li>Date (compact format)</li>
|
||||||
|
<li>Sender & receiver chips (abbreviated)</li>
|
||||||
|
<li>Action buttons (Edit, Annotate, Download)</li>
|
||||||
|
<li><strong>“Details”</strong> toggle button</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:200px;">
|
||||||
|
<div style="font-weight:600;color:var(--orange);margin-bottom:4px;">Revealed in drawer</div>
|
||||||
|
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||||
|
<li>Full date (long format)</li>
|
||||||
|
<li>Creation location (e.g. “Breslau”)</li>
|
||||||
|
<li>Archive location (e.g. “Ordner A3, Schublade 2”)</li>
|
||||||
|
<li>Tags (clickable → filter documents)</li>
|
||||||
|
<li>Full person cards with avatar, name, alias</li>
|
||||||
|
<li>Person detail links (/persons/{id})</li>
|
||||||
|
<li>Conversation links (/korrespondenz?...)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
DESKTOP — COLLAPSED
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="collapsed">
|
||||||
|
<div class="scr-head"><h3>Desktop — collapsed (default)</h3><span class="scr-id">S1</span></div>
|
||||||
|
<div class="scr-desc">The topbar looks identical to today except for the “Details” button between the person chips and the action buttons. The split view gets the full remaining viewport.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px · collapsed</div>
|
||||||
|
<div class="desk" style="min-height:480px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="fa-link">Dokumente</div>
|
||||||
|
<div class="fa-link">Personen</div>
|
||||||
|
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-main">
|
||||||
|
<div class="back">←</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></div>
|
||||||
|
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="fa-chip"><div class="av navy">HR</div> <a href="#">Heinrich R.</a></div>
|
||||||
|
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||||
|
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<!-- Labeled toggle button -->
|
||||||
|
<div class="details-toggle">Details <span class="chevron-icon">▼</span></div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||||
|
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Split view fills remaining space -->
|
||||||
|
<div class="split" style="flex:1;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="pdf-area" style="height:100%;">
|
||||||
|
<div class="paper" style="width:50%;min-height:180px;position:relative;">
|
||||||
|
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||||
|
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||||
|
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
|
||||||
|
<div class="ann-rect" style="left:2%;top:0%;width:50%;height:12%;"><div class="ann-num">1</div></div>
|
||||||
|
<div class="ann-rect" style="left:2%;top:16%;width:96%;height:35%;"><div class="ann-num">2</div></div>
|
||||||
|
<div class="ann-rect" style="left:2%;top:55%;width:96%;height:25%;"><div class="ann-num">3</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="split-handle"></div>
|
||||||
|
<div style="width:360px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;">
|
||||||
|
<div style="background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:4px 8px;gap:4px;flex-shrink:0;">
|
||||||
|
<span style="font-size:7px;font-weight:600;color:var(--navy);">3 Blöcke</span>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<span style="font-size:7px;color:var(--green-dark);">✓ Gespeichert</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div class="tblock"><div class="tblock-head"><div class="num">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div><div class="tblock-body">Liebe Martha,</div></div>
|
||||||
|
<div class="tblock active"><div class="tblock-head" style="background:rgba(0,199,177,.08);"><div class="num">2</div> Hauptteil</div><div class="tblock-body">ich schreibe Dir heute aus dem Lazarett in Breslau...<span class="trans-cursor"></span></div></div>
|
||||||
|
<div class="tblock"><div class="tblock-head"><div class="num">3</div> Familie</div><div class="tblock-body" style="color:var(--color-text-muted);font-style:italic;">noch leer</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar"><span>Block 2 aktiv</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
DESKTOP — EXPANDED
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="expanded">
|
||||||
|
<div class="scr-head"><h3>Desktop — expanded</h3><span class="scr-id">S2</span></div>
|
||||||
|
<div class="scr-desc">Clicking “Details” slides open a full-width drawer below the topbar. Three-column grid: details (date, location, archive), persons (sender & receiver cards with conversation links), and tags. The drawer <strong>pushes content down</strong> — it is part of the document flow, not an overlay. No clipping, no z-index issues.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px · expanded</div>
|
||||||
|
<div class="desk" style="min-height:540px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="fa-link">Dokumente</div>
|
||||||
|
<div class="fa-link">Personen</div>
|
||||||
|
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar" style="border-bottom:none;">
|
||||||
|
<div class="topbar-main">
|
||||||
|
<div class="back">←</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></div>
|
||||||
|
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="fa-chip"><div class="av navy">HR</div> <a href="#">Heinrich R.</a></div>
|
||||||
|
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||||
|
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<!-- Toggle: active state -->
|
||||||
|
<div class="details-toggle open">Details <span class="chevron-icon">▼</span></div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="fa-topbar-btn transcribe">✎ Transkribieren</div>
|
||||||
|
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expanded metadata drawer -->
|
||||||
|
<div style="border-top:1px solid #e4e2d7;padding:10px 16px;display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;background:var(--color-page);">
|
||||||
|
<!-- Column 1: Details -->
|
||||||
|
<div>
|
||||||
|
<div class="meta-label" style="margin-bottom:5px;">Details</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:5px;">
|
||||||
|
<div class="meta-icon">📅</div>
|
||||||
|
<div><div class="meta-value">14. Mai 1943</div><div class="meta-label">Datum</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:5px;">
|
||||||
|
<div class="meta-icon">📍</div>
|
||||||
|
<div><div class="meta-value">Breslau</div><div class="meta-label">Entstehungsort</div></div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:5px;">
|
||||||
|
<div class="meta-icon">📁</div>
|
||||||
|
<div><div class="meta-value">Ordner A3, Schublade 2</div><div class="meta-label">Archivstandort</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column 2: Persons -->
|
||||||
|
<div>
|
||||||
|
<div class="meta-label" style="margin-bottom:5px;">Personen</div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div class="meta-label" style="margin-top:2px;">Absender</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="pc-av" style="background:var(--navy);color:var(--mint);">HR</div>
|
||||||
|
<div><div class="pc-name">Heinrich Raddatz</div><div class="pc-alias">Opa Heinrich</div></div>
|
||||||
|
<div class="pc-action">💬</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-label" style="margin-top:4px;">Empfänger</div>
|
||||||
|
<div class="person-card">
|
||||||
|
<div class="pc-av" style="background:#5A3080;color:#fff;">MR</div>
|
||||||
|
<div><div class="pc-name">Martha Raddatz</div><div class="pc-alias">Oma Martha</div></div>
|
||||||
|
<div class="pc-action">💬</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column 3: Tags -->
|
||||||
|
<div>
|
||||||
|
<div class="meta-label" style="margin-bottom:5px;">Schlagwörter</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:3px;">
|
||||||
|
<div class="tag-chip">Feldpost</div>
|
||||||
|
<div class="tag-chip">2. Weltkrieg</div>
|
||||||
|
<div class="tag-chip">Lazarett</div>
|
||||||
|
<div class="tag-chip">Breslau</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="border-top:1px solid #e4e2d7;"></div>
|
||||||
|
|
||||||
|
<!-- Split view (pushed down) -->
|
||||||
|
<div class="split" style="flex:1;">
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="pdf-area" style="height:100%;">
|
||||||
|
<div class="paper" style="width:50%;min-height:140px;position:relative;">
|
||||||
|
<div style="font-size:7px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||||
|
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="split-handle"></div>
|
||||||
|
<div style="width:360px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;">
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:6px 8px;background:#fff;display:flex;flex-direction:column;gap:4px;">
|
||||||
|
<div class="tblock"><div class="tblock-head"><div class="num">1</div> Anrede</div><div class="tblock-body">Liebe Martha,</div></div>
|
||||||
|
<div class="tblock active"><div class="tblock-head" style="background:rgba(0,199,177,.08);"><div class="num">2</div> Hauptteil</div><div class="tblock-body">ich schreibe Dir heute...<span class="trans-cursor"></span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar"><span>Block 2 aktiv</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
MOBILE — COLLAPSED
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="mobile-collapsed">
|
||||||
|
<div class="scr-head"><h3>Mobile — collapsed</h3><span class="scr-id">S3</span></div>
|
||||||
|
<div class="scr-desc">On mobile, the topbar shows the title, a compact “Details” toggle, and the transcribe mode pill. Person chips are hidden (shown in drawer instead). The toggle provides a 44px tap target.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px · collapsed</div>
|
||||||
|
<div class="phone" style="height:560px;">
|
||||||
|
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||||
|
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||||
|
<div class="details-toggle" style="font-size:8px;padding:4px 10px 4px 8px;min-height:28px;">Details <span class="chevron-icon" style="font-size:8px;">▼</span></div>
|
||||||
|
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- PDF strip -->
|
||||||
|
<div style="background:#D4D0C8;height:110px;display:flex;align-items:center;justify-content:center;border-bottom:2px solid var(--turquoise);">
|
||||||
|
<div style="background:#FFFEF8;width:40%;padding:6px 8px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;position:relative;">
|
||||||
|
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||||
|
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
|
||||||
|
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:70%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Transcript blocks -->
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||||
|
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||||
|
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede <span style="margin-left:auto;color:var(--green-dark);">✓</span></div>
|
||||||
|
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
|
||||||
|
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil</div>
|
||||||
|
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">ich schreibe Dir heute aus dem Lazarett in Breslau...<span class="trans-cursor"></span></div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||||
|
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">3</div> Familie</div>
|
||||||
|
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;color:var(--color-text-muted);font-style:italic;">noch leer</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
MOBILE — EXPANDED
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="mobile-expanded">
|
||||||
|
<div class="scr-head"><h3>Mobile — expanded</h3><span class="scr-id">S4</span></div>
|
||||||
|
<div class="scr-desc">The drawer opens as a single-column stack below the topbar. Person cards are full-width with 44px minimum touch targets. Conversation links are always visible (no hover-reveal on touch). Tags wrap naturally. The PDF strip and transcript blocks are pushed down.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px · expanded</div>
|
||||||
|
<div class="phone" style="height:620px;">
|
||||||
|
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div style="background:#fff;border-bottom:none;padding:6px 12px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||||
|
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||||
|
<div class="details-toggle open" style="font-size:8px;padding:4px 10px 4px 8px;min-height:28px;">Details <span class="chevron-icon" style="font-size:8px;">▼</span></div>
|
||||||
|
<span style="font-size:7px;font-weight:700;padding:2px 6px;border-radius:3px;background:var(--turquoise);color:var(--navy);">Transkr.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Expanded drawer: single-column -->
|
||||||
|
<div style="background:var(--color-page);border-bottom:1px solid #e4e2d7;padding:10px 12px;">
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||||
|
<div style="flex:1;min-width:100px;">
|
||||||
|
<div class="meta-label">Datum</div>
|
||||||
|
<div class="meta-value">14. Mai 1943</div>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;min-width:100px;">
|
||||||
|
<div class="meta-label">Ort</div>
|
||||||
|
<div class="meta-value">Breslau</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;">
|
||||||
|
<div style="flex:1;min-width:100px;">
|
||||||
|
<div class="meta-label">Archivstandort</div>
|
||||||
|
<div class="meta-value">Ordner A3, Schublade 2</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-label" style="margin-bottom:3px;">Absender</div>
|
||||||
|
<div class="person-card" style="margin-bottom:4px;min-height:36px;">
|
||||||
|
<div class="pc-av" style="background:var(--navy);color:var(--mint);">HR</div>
|
||||||
|
<div><div class="pc-name">Heinrich Raddatz</div><div class="pc-alias">Opa Heinrich</div></div>
|
||||||
|
<div class="pc-action" style="opacity:1;">💬</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-label" style="margin-bottom:3px;">Empfänger</div>
|
||||||
|
<div class="person-card" style="margin-bottom:6px;min-height:36px;">
|
||||||
|
<div class="pc-av" style="background:#5A3080;color:#fff;">MR</div>
|
||||||
|
<div><div class="pc-name">Martha Raddatz</div><div class="pc-alias">Oma Martha</div></div>
|
||||||
|
<div class="pc-action" style="opacity:1;">💬</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-label" style="margin-bottom:3px;">Schlagwörter</div>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:3px;">
|
||||||
|
<div class="tag-chip">Feldpost</div>
|
||||||
|
<div class="tag-chip">2. Weltkrieg</div>
|
||||||
|
<div class="tag-chip">Lazarett</div>
|
||||||
|
<div class="tag-chip">Breslau</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- PDF strip (pushed down) -->
|
||||||
|
<div style="background:#D4D0C8;height:70px;display:flex;align-items:center;justify-content:center;border-bottom:2px solid var(--turquoise);">
|
||||||
|
<div style="background:#FFFEF8;width:40%;padding:4px 6px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;">
|
||||||
|
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||||
|
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Blocks -->
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:8px 12px;background:#fff;">
|
||||||
|
<div style="border:1px solid var(--color-border);border-radius:5px;overflow:hidden;margin-bottom:6px;">
|
||||||
|
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:var(--sand);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">1</div> Anrede</div>
|
||||||
|
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">Liebe Martha,</div>
|
||||||
|
</div>
|
||||||
|
<div style="border:1px solid var(--turquoise);border-radius:5px;overflow:hidden;margin-bottom:6px;box-shadow:0 0 0 1px var(--turquoise);">
|
||||||
|
<div style="padding:3px 8px;font-size:6px;font-weight:600;color:var(--color-text-muted);display:flex;align-items:center;gap:3px;background:rgba(0,199,177,.08);"><div style="width:12px;height:12px;border-radius:50%;background:var(--navy);color:#fff;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:700;">2</div> Hauptteil</div>
|
||||||
|
<div style="padding:4px 8px;font-family:Georgia,serif;font-size:10px;line-height:1.6;">ich schreibe Dir heute...<span class="trans-cursor"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
NON-TRANSCRIBE MODE (standard document view)
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="non-transcribe">
|
||||||
|
<div class="scr-head"><h3>Non-transcribe mode — standard document view</h3><span class="scr-id">S5</span></div>
|
||||||
|
<div class="scr-desc">Outside of transcribe mode, the document detail page uses the <strong>same “Details” drawer pattern</strong>. No bottom panel. The PDF gets the full remaining viewport. Discussion and transcription are accessible via dedicated buttons (Transkribieren enters split mode, Annotieren enters annotation mode). One consistent pattern everywhere — no mode-dependent UI structure.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Desktop · non-transcribe · collapsed</div>
|
||||||
|
<div class="desk" style="min-height:400px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="fa-link">Dokumente</div>
|
||||||
|
<div class="fa-link">Personen</div>
|
||||||
|
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="topbar-main">
|
||||||
|
<div class="back">←</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 2px;"></div>
|
||||||
|
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="fa-chip"><div class="av navy">HR</div> <a href="#">Heinrich R.</a></div>
|
||||||
|
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||||
|
<div class="fa-chip"><div class="av purple">MR</div> <a href="#">Martha R.</a></div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="details-toggle">Details <span class="chevron-icon">▼</span></div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="fa-topbar-btn ghost">✎ Transkribieren</div>
|
||||||
|
<div class="fa-topbar-btn ghost">Annotieren</div>
|
||||||
|
<div class="fa-topbar-btn ghost" style="padding:3px 5px;">
|
||||||
|
<span style="font-size:8px;">✎</span>
|
||||||
|
</div>
|
||||||
|
<div style="width:14px;height:14px;border-radius:3px;background:var(--sand);display:flex;align-items:center;justify-content:center;font-size:6px;">⇩</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full PDF — no bottom panel -->
|
||||||
|
<div class="pdf-area" style="flex:1;">
|
||||||
|
<div class="paper" style="width:45%;min-height:240px;">
|
||||||
|
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||||
|
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||||
|
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══ AGENT TABLE ═══ -->
|
||||||
|
<div class="agent">
|
||||||
|
<h4>Expandable metadata header · Implementation spec</h4>
|
||||||
|
<pre>/* The topbar gains a labeled "Details ▼" toggle button that opens a full-width metadata
|
||||||
|
* drawer below the main topbar row.
|
||||||
|
*
|
||||||
|
* Collapsed (default): topbar looks like today + a "Details ▼" button between
|
||||||
|
* the person chips and the action buttons.
|
||||||
|
* Expanded: a new row slides down with a 3-column grid (desktop):
|
||||||
|
* Col 1: date (long format), location, archive location — icon + value + label
|
||||||
|
* Col 2: sender card + receiver cards — clickable, links to /persons/{id}
|
||||||
|
* conversation icon links to /korrespondenz?senderId=X&receiverId=Y
|
||||||
|
* Col 3: tag chips — clickable, link to /?tag=X
|
||||||
|
*
|
||||||
|
* The drawer PUSHES content down (document flow, not overlay).
|
||||||
|
* Background: color-page (sand) to visually separate from white topbar.
|
||||||
|
* Animation: Svelte slide transition or max-height + overflow:hidden, 200ms ease.
|
||||||
|
*
|
||||||
|
* KEY DECISION: "Details ▼" labeled toggle instead of bare chevron icon.
|
||||||
|
* Reason: 60+ year old users in user interviews — bare icons are easy to miss.
|
||||||
|
* The label makes the interaction self-explanatory and provides a 44×28px min tap target.
|
||||||
|
*
|
||||||
|
* Mobile: single-column stack, person cards full-width with 44px min-height,
|
||||||
|
* conversation links always visible (no hover-reveal on touch). */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Toggle button</td></tr>
|
||||||
|
<tr><td>Label</td><td>"Details" + ▼ chevron</td><td>i18n key: topbar_details_toggle</td></tr>
|
||||||
|
<tr><td>Size</td><td>min 44×28px tap target, text-xs font-semibold</td><td>WCAG 2.5.5 compliant target size</td></tr>
|
||||||
|
<tr><td>Inactive style</td><td>border border-line, text-ink-2, bg-transparent</td><td>Subtle, doesn't compete with action buttons</td></tr>
|
||||||
|
<tr><td>Active style</td><td>bg-primary, text-primary-fg, border-primary</td><td>Clear open state — matches annotate button pattern</td></tr>
|
||||||
|
<tr><td>Chevron</td><td>▼ (U+25BC), rotates 180deg when open</td><td>CSS transition transform 200ms</td></tr>
|
||||||
|
<tr><td>Aria</td><td>aria-expanded, aria-controls="metadata-drawer"</td><td>Button role implicit</td></tr>
|
||||||
|
<tr><td>Keyboard</td><td>Ctrl+M toggles, Escape closes</td><td>Ctrl+M matches "M for metadata"</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Drawer (expanded)</td></tr>
|
||||||
|
<tr><td>Layout</td><td>grid 3-col desktop (1fr 1fr 1fr), 1-col mobile</td><td>bg:color-page, border-top:line, p:12px 16px</td></tr>
|
||||||
|
<tr><td>Animation</td><td>Svelte slide transition, 200ms</td><td>Or CSS max-height 0↔auto with overflow:hidden</td></tr>
|
||||||
|
<tr><td>Push behavior</td><td>In document flow, pushes split view down</td><td>Not absolute/overlay — no clipping</td></tr>
|
||||||
|
<tr><td>ID</td><td>id="metadata-drawer"</td><td>role="region", aria-label="Dokumentmetadaten"</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Drawer content — Details column</td></tr>
|
||||||
|
<tr><td>Date</td><td>Long format (14. Mai 1943), icon 📅</td><td>Uses existing formatDate utility</td></tr>
|
||||||
|
<tr><td>Location</td><td>Text, icon 📍</td><td>Only shown if doc.creationLocation exists</td></tr>
|
||||||
|
<tr><td>Archive</td><td>Text, icon 📁</td><td>Only shown if doc.archiveLocation exists</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Drawer content — Persons column</td></tr>
|
||||||
|
<tr><td>Person card</td><td>border:line, radius:5px, bg:page, hover:accent-bg</td><td>Entire card is a link to /persons/{id}</td></tr>
|
||||||
|
<tr><td>Card content</td><td>18px avatar + full name + alias</td><td>Alias from person.alias field</td></tr>
|
||||||
|
<tr><td>Conversation icon</td><td>💬 appears on hover (desktop), always visible (mobile)</td><td>Links to /korrespondenz?senderId=X&receiverId=Y</td></tr>
|
||||||
|
<tr><td>Mobile card height</td><td>min-height 44px</td><td>WCAG touch target compliance</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Drawer content — Tags column</td></tr>
|
||||||
|
<tr><td>Chip</td><td>text-[10px]/600, sand bg, uppercase, radius:3px</td><td>Click → navigate to /?tag=X</td></tr>
|
||||||
|
<tr><td>Hover</td><td>bg-primary, text-primary-fg</td><td>Visual feedback that chips are interactive</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Non-transcribe mode</td></tr>
|
||||||
|
<tr><td>Toggle shown?</td><td>Yes — always present in topbar</td><td>Consistent UX across all modes</td></tr>
|
||||||
|
<tr><td>Bottom panel</td><td>Removed entirely — all modes</td><td>Drawer is the single metadata pattern everywhere</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||||
|
<div class="llm">
|
||||||
|
<h2>Implementation Guide — Expandable Metadata Header</h2>
|
||||||
|
|
||||||
|
<h3>1. Scope</h3>
|
||||||
|
<p>Add a labeled “Details” toggle button and a collapsible metadata drawer to <code>DocumentTopBar.svelte</code>. This spec covers <strong>only the header expansion</strong> — the transcription split view, inline comments, and history toolbar are covered in the companion spec (<code>annotation-transcription-final-spec.html</code>).</p>
|
||||||
|
|
||||||
|
<h3>2. State</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>let metadataOpen = $state(false)</code> in <code>DocumentTopBar.svelte</code>.</li>
|
||||||
|
<li>Toggle on button click. Close on Escape key. Toggle on Ctrl+M.</li>
|
||||||
|
<li>State is local — not persisted. Defaults to closed on every page load.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3. Component Changes</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Component</th><th>Change</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>DocumentTopBar.svelte</code></td><td>Add <code>metadataOpen</code> state, toggle button, and conditional drawer div. New props needed: <code>doc.creationLocation</code>, <code>doc.archiveLocation</code>, <code>doc.tags</code>, full sender/receiver objects with aliases.</td></tr>
|
||||||
|
<tr><td><code>MetadataDrawer.svelte</code> (new)</td><td>Extracted child component. Receives the doc object. Renders the 3-column grid (desktop) or 1-column stack (mobile). Contains person cards, tag chips, and metadata fields.</td></tr>
|
||||||
|
<tr><td><code>PersonChipRow.svelte</code></td><td>No change. Still renders the abbreviated chips in the main topbar row.</td></tr>
|
||||||
|
<tr><td><code>DocumentBottomPanel.svelte</code></td><td>Remove entirely. The metadata drawer replaces the Metadata tab. Transcription, Discussion, and History move to inline UI (see companion transcription spec). No bottom panel in any mode.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>4. Toggle Button Placement</h3>
|
||||||
|
<p>In the topbar’s flex row, the button goes <strong>after the person chips divider and before the action buttons divider</strong>:</p>
|
||||||
|
<p><code>← | Title | chips → | <strong>Details ▼</strong> | Transkribieren | Annotieren | Edit | Download</code></p>
|
||||||
|
<p>On mobile (<375px), person chips are hidden. The toggle sits after the title, before the transcribe pill.</p>
|
||||||
|
|
||||||
|
<h3>5. Drawer Markup</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Use Svelte <code>slide</code> transition: <code>{#if metadataOpen}<div transition:slide={{ duration: 200 }}></code></li>
|
||||||
|
<li>The drawer is a direct child of the topbar wrapper, below the main flex row.</li>
|
||||||
|
<li>Desktop: <code>grid grid-cols-3 gap-4 p-3 sm:p-4 bg-canvas border-t border-line</code></li>
|
||||||
|
<li>Mobile: <code>grid grid-cols-1 gap-3 p-3 bg-canvas border-t border-line</code></li>
|
||||||
|
<li>Breakpoint for 3-col: <code>md:grid-cols-3</code> (768px+).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>6. Person Cards in Drawer</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Each card: avatar (using <code>personAvatarColor</code>), full name (font-serif), alias (text-xs text-ink-2).</li>
|
||||||
|
<li>Card wraps an <code><a href="/persons/{id}"></code>.</li>
|
||||||
|
<li>Conversation icon: separate <code><a></code> inside the card, absolute-positioned or flex-end. Links to <code>/korrespondenz?senderId={sender.id}&receiverId={receiver.id}</code>.</li>
|
||||||
|
<li>On mobile: <code>min-h-[44px]</code> for touch targets. Conversation icon always visible (<code>opacity-100</code> instead of <code>opacity-0 group-hover:opacity-100</code>).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>7. Tag Chips in Drawer</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Each tag: <code><a href="/?tag={tag.name}"></code> with <code>text-[10px] font-semibold uppercase bg-muted rounded px-2 py-0.5 hover:bg-primary hover:text-primary-fg transition-colors</code>.</li>
|
||||||
|
<li><code>aria-label="Dokumente mit Schlagwort {tag.name} filtern"</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>8. Accessibility</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Toggle button: <code>aria-expanded={metadataOpen}</code>, <code>aria-controls="metadata-drawer"</code>.</li>
|
||||||
|
<li>Drawer: <code>id="metadata-drawer"</code>, <code>role="region"</code>, <code>aria-label="Dokumentmetadaten"</code>.</li>
|
||||||
|
<li>Person cards: accessible name includes full name + “Zur Personenseite”.</li>
|
||||||
|
<li>Conversation link: <code>aria-label="Korrespondenz zwischen {sender} und {receiver} anzeigen"</code>.</li>
|
||||||
|
<li>Tab order: toggle button → drawer contents (when open) → action buttons.</li>
|
||||||
|
<li>Escape closes drawer and returns focus to the toggle button.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>9. i18n Keys</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Key</th><th>de</th><th>en</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>topbar_details_toggle</code></td><td>Details</td><td>Details</td></tr>
|
||||||
|
<tr><td><code>topbar_details_date</code></td><td>Datum</td><td>Date</td></tr>
|
||||||
|
<tr><td><code>topbar_details_location</code></td><td>Entstehungsort</td><td>Location</td></tr>
|
||||||
|
<tr><td><code>topbar_details_archive</code></td><td>Archivstandort</td><td>Archive location</td></tr>
|
||||||
|
<tr><td><code>topbar_details_sender</code></td><td>Absender</td><td>Sender</td></tr>
|
||||||
|
<tr><td><code>topbar_details_receivers</code></td><td>Empfänger</td><td>Receivers</td></tr>
|
||||||
|
<tr><td><code>topbar_details_tags</code></td><td>Schlagwörter</td><td>Tags</td></tr>
|
||||||
|
<tr><td><code>topbar_details_conversation</code></td><td>Korrespondenz anzeigen</td><td>View correspondence</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1152
docs/specs/focus-rings-spec.html
Normal file
1152
docs/specs/focus-rings-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
855
docs/specs/header-nav-redesign-spec.html
Normal file
855
docs/specs/header-nav-redesign-spec.html
Normal file
@@ -0,0 +1,855 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Header / Navigation Redesign Spec · Familienarchiv</title>
|
||||||
|
<style>
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||||
|
body{font-family:'Helvetica Neue',Arial,sans-serif;background:#ECEAE4;color:#1A1A1A;line-height:1.5}
|
||||||
|
.doc{max-width:1440px;margin:0 auto;padding:48px 32px}
|
||||||
|
|
||||||
|
/* ── Masthead ─── */
|
||||||
|
.mast{background:#0D2240;border-radius:10px;padding:32px 40px;margin-bottom:48px}
|
||||||
|
.mast-top{display:flex;align-items:flex-start;justify-content:space-between;gap:24px;margin-bottom:16px}
|
||||||
|
.mast h1{font-size:22px;font-weight:900;color:#fff;letter-spacing:-.4px;margin-bottom:6px}
|
||||||
|
.mast p{font-size:12px;color:rgba(255,255,255,.5);max-width:620px;line-height:1.7}
|
||||||
|
.mast-badge{font-size:9px;font-weight:800;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.8px;white-space:nowrap;flex-shrink:0;margin-top:4px}
|
||||||
|
.mb-draft{background:#FCD34D;color:#78350F}
|
||||||
|
.decisions{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:20px;border-top:1px solid rgba(255,255,255,.1);padding-top:16px}
|
||||||
|
.dec{background:rgba(255,255,255,.06);border-radius:6px;padding:10px 12px}
|
||||||
|
.dec-label{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.35);margin-bottom:5px}
|
||||||
|
.dec-value{font-size:9.5px;font-weight:700;color:#fff;line-height:1.5}
|
||||||
|
.dec-value s{color:rgba(255,255,255,.3);font-weight:400}
|
||||||
|
|
||||||
|
/* ── Section headings ─── */
|
||||||
|
.sec{margin-bottom:64px}
|
||||||
|
.sec+.sec{border-top:2px dashed #C8C4BE;padding-top:56px}
|
||||||
|
.sec-h{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:1.2px;color:#888;margin-bottom:20px;display:flex;align-items:center;gap:10px}
|
||||||
|
.sec-h::after{content:'';flex:1;height:1px;background:#D8D4CE}
|
||||||
|
.sec-num{background:#0D2240;color:#fff;font-size:9px;font-weight:900;padding:2px 7px;border-radius:10px}
|
||||||
|
|
||||||
|
/* ── Screen grid ─── */
|
||||||
|
.sg{display:grid;gap:20px;align-items:start}
|
||||||
|
.sg-3{grid-template-columns:1fr 1fr 1fr}
|
||||||
|
.sg-2{grid-template-columns:1fr 1fr}
|
||||||
|
.sg-2a{grid-template-columns:1.3fr 1fr}
|
||||||
|
.sg-mob{grid-template-columns:1fr 220px}
|
||||||
|
.sb{display:flex;flex-direction:column}
|
||||||
|
.sl{font-size:9px;font-weight:800;color:#888;text-transform:uppercase;letter-spacing:1.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px}
|
||||||
|
.sz{background:#E8E4DF;color:#666;padding:1px 5px;border-radius:3px;font-size:8px}
|
||||||
|
.state{padding:1px 6px;border-radius:3px;font-size:8px;font-weight:700}
|
||||||
|
.st-bad{background:#FEE2E2;color:#991B1B}
|
||||||
|
.st-good{background:#DCFCE7;color:#166534}
|
||||||
|
.st-warn{background:#FEF3C7;color:#92400E}
|
||||||
|
.sc{font-size:8.5px;color:#888;margin-top:6px;font-style:italic;line-height:1.5}
|
||||||
|
|
||||||
|
/* ── Annotation callouts ─── */
|
||||||
|
.ann{display:inline-block;font-size:7.5px;font-weight:700;color:#C2410C;background:#FFF7ED;border:1px solid #FDBA74;border-radius:3px;padding:1px 5px;white-space:nowrap}
|
||||||
|
.ann-block{background:#FFF7ED;border:1px solid #FDBA74;border-radius:5px;padding:8px 10px;font-size:10px;color:#7C2D12;line-height:1.5;margin-top:10px}
|
||||||
|
.ann-block strong{font-weight:800}
|
||||||
|
.ann-block ul{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
|
||||||
|
.ann-block ol{padding-left:14px;display:flex;flex-direction:column;gap:3px;margin-top:5px}
|
||||||
|
|
||||||
|
/* ── Wireframe Chrome ─── */
|
||||||
|
.wf{background:#fff;border:2px solid #B8B4AE;border-radius:10px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08)}
|
||||||
|
.wf-bar{height:24px;background:#E8E4DF;border-bottom:1px solid #C8C4BE;display:flex;align-items:center;padding:0 9px;gap:4px}
|
||||||
|
.dot{width:7px;height:7px;border-radius:50%;background:#C8C4BE}
|
||||||
|
.dot.r{background:#F87171}.dot.y{background:#FCD34D}.dot.g{background:#4ADE80}
|
||||||
|
.urlbar{flex:1;height:11px;background:#D8D4CE;border-radius:3px;margin-left:6px;display:flex;align-items:center;padding:0 5px}
|
||||||
|
.urlbar span{font-size:7.5px;color:#888;font-family:monospace}
|
||||||
|
|
||||||
|
/* ── Current header (white/broken) ─── */
|
||||||
|
.H-OLD{height:44px;background:#ffffff;border-bottom:1.5px solid #E5E7EB;display:flex;align-items:center;padding:0 16px;gap:14px;position:relative}
|
||||||
|
.H-OLD-LOGO{font-size:9px;font-weight:900;color:#012851;letter-spacing:1.2px;font-family:'Arial Black',sans-serif}
|
||||||
|
.H-OLD-NAV{display:flex;gap:10px;align-items:center;margin-left:8px}
|
||||||
|
.H-OLD-LINK{font-size:7.5px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#012851;padding:3px 7px;border-radius:4px}
|
||||||
|
.H-OLD-LINK.act{background:rgba(180,185,255,0.15);color:#012851}
|
||||||
|
.H-OLD-R{margin-left:auto;display:flex;gap:7px;align-items:center}
|
||||||
|
.H-OLD-ICO{width:22px;height:22px;background:#F3F4F6;border-radius:4px;border:1px solid #E5E7EB}
|
||||||
|
.H-OLD-AV{width:22px;height:22px;background:#012851;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:900;color:#fff}
|
||||||
|
|
||||||
|
/* ── NEW header atoms ─── */
|
||||||
|
.STRIP{height:4px;background:#B4B9FF} /* brand-purple accent strip */
|
||||||
|
.N{height:42px;background:#012851;display:flex;align-items:center;padding:0 16px;gap:14px;flex-shrink:0}
|
||||||
|
.logo{font-size:9px;font-weight:900;color:#fff;letter-spacing:1.2px;font-family:'Arial Black',sans-serif}
|
||||||
|
.nl{font-size:7.5px;color:rgba(255,255,255,.55);font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding-bottom:2px}
|
||||||
|
.nl:hover{color:rgba(255,255,255,.85)}
|
||||||
|
.nl.on{color:#fff;border-bottom:2px solid #A1DCD8;padding-bottom:2px}
|
||||||
|
.nr{margin-left:auto;display:flex;gap:8px;align-items:center}
|
||||||
|
.nico{width:20px;height:20px;background:rgba(255,255,255,.1);border-radius:4px}
|
||||||
|
.nico-av{width:22px;height:22px;background:#A1DCD8;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:6px;font-weight:900;color:#012851}
|
||||||
|
.nico-lbl{font-size:7px;color:rgba(255,255,255,.6);font-weight:700;text-transform:uppercase}
|
||||||
|
|
||||||
|
/* ── Page body placeholder ─── */
|
||||||
|
.MAIN{padding:14px 18px;display:flex;flex-direction:column;gap:10px;background:#ECEAE4;min-height:80px}
|
||||||
|
.PH{height:7px;background:#D8D4CE;border-radius:2px;margin-bottom:4px}
|
||||||
|
.w80{width:80%}.w70{width:70%}.w60{width:60%}.w50{width:50%}.w40{width:40%}.w30{width:30%}
|
||||||
|
|
||||||
|
/* ── Mobile chrome ─── */
|
||||||
|
.WF-M{background:#fff;border:2px solid #B8B4AE;border-radius:16px;overflow:hidden;box-shadow:0 4px 18px rgba(0,0,0,.08);width:220px}
|
||||||
|
.WF-M-STATUS{height:18px;background:#012851;display:flex;align-items:center;justify-content:space-between;padding:0 10px}
|
||||||
|
.WF-M-TIME{font-size:7px;color:#fff;font-weight:700}
|
||||||
|
.WF-M-ICONS{display:flex;gap:3px}
|
||||||
|
.WF-M-ICON{width:6px;height:6px;background:rgba(255,255,255,.5);border-radius:1px}
|
||||||
|
.N-M{height:42px;display:flex;align-items:center;padding:0 12px;justify-content:space-between}
|
||||||
|
.HAMBURGER{display:flex;flex-direction:column;gap:3px;justify-content:center;width:18px}
|
||||||
|
.HAMBURGER-LINE{height:1.5px;background:rgba(255,255,255,.85);border-radius:1px}
|
||||||
|
|
||||||
|
/* Mobile old (white bg) */
|
||||||
|
.N-M-OLD{height:44px;background:#ffffff;border-bottom:1.5px solid #E5E7EB;display:flex;align-items:center;padding:0 12px;justify-content:space-between}
|
||||||
|
.H-OLD-M-LOGO{font-size:9px;font-weight:900;color:#012851;letter-spacing:1.2px}
|
||||||
|
|
||||||
|
/* Nav drawer */
|
||||||
|
.DRAWER{background:#fff;border-top:1px solid #E5E7EB;padding:10px 0}
|
||||||
|
.DRAWER-LINK{display:flex;align-items:center;padding:8px 14px;font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#012851;border-left:3px solid transparent}
|
||||||
|
.DRAWER-LINK.on{border-left-color:#A1DCD8;background:#F0EFE9;color:#012851}
|
||||||
|
.DRAWER-LINK.off{color:rgba(1,40,81,.55)}
|
||||||
|
.DRAWER-DIV{height:1px;background:#E5E7EB;margin:6px 14px}
|
||||||
|
.DRAWER-LANG{display:flex;align-items:center;gap:8px;padding:6px 14px}
|
||||||
|
.DRAWER-LANG-BTN{font-size:7.5px;font-weight:700;color:#012851;padding:2px 7px;border-radius:3px;border:1.5px solid #D1D5DB}
|
||||||
|
.DRAWER-LANG-BTN.on{border-color:#012851;background:#012851;color:#fff}
|
||||||
|
|
||||||
|
/* ── Nav state grid ─── */
|
||||||
|
.NSG{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
|
||||||
|
.NS{display:flex;flex-direction:column;gap:6px}
|
||||||
|
.NS-LABEL{font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888}
|
||||||
|
.NS-DEMO{height:40px;background:#012851;display:flex;align-items:center;padding:0 14px;border-radius:6px}
|
||||||
|
.NS-LINK{font-size:8px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding-bottom:2px}
|
||||||
|
.NS-INACTIVE{color:rgba(255,255,255,.55)}
|
||||||
|
.NS-HOVER{color:rgba(255,255,255,.85)}
|
||||||
|
.NS-ACTIVE{color:#fff;border-bottom:2px solid #A1DCD8}
|
||||||
|
.NS-FOCUS{color:#fff;outline:2px solid #A1DCD8;outline-offset:3px;border-radius:2px;padding:1px 3px}
|
||||||
|
|
||||||
|
/* ── Contrast badge ─── */
|
||||||
|
.contrast-badge{display:inline-flex;align-items:center;gap:4px;font-size:8px;font-weight:700;padding:2px 7px;border-radius:20px}
|
||||||
|
.cr-fail{background:#FEE2E2;color:#991B1B}
|
||||||
|
.cr-pass{background:#DCFCE7;color:#166534}
|
||||||
|
|
||||||
|
/* ── Login page ─── */
|
||||||
|
.LOGIN-BG{background:#F0EFE9;min-height:120px;display:flex;flex-direction:column;align-items:center;padding-bottom:14px}
|
||||||
|
.LOGIN-HEADER{width:100%;height:4px;background:#B4B9FF}
|
||||||
|
.LOGIN-NAV{width:100%;height:42px;background:#012851;display:flex;align-items:center;justify-content:space-between;padding:0 16px}
|
||||||
|
.LOGIN-CARD{background:#fff;border:1.5px solid #D8D4CE;border-radius:8px;padding:14px 18px;width:200px;margin-top:14px}
|
||||||
|
.LOGIN-CARD-TITLE{font-size:10px;font-weight:900;color:#012851;margin-bottom:10px;letter-spacing:-.2px}
|
||||||
|
.LOGIN-FIELD{margin-bottom:7px}
|
||||||
|
.LOGIN-FIELD-L{font-size:7px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:3px}
|
||||||
|
.LOGIN-FIELD-I{height:26px;border:1.5px solid #D1D5DB;border-radius:3px;background:#fff;width:100%}
|
||||||
|
.LOGIN-BTN{height:28px;background:#012851;border-radius:3px;width:100%;display:flex;align-items:center;justify-content:center;font-size:8px;font-weight:800;color:#fff;text-transform:uppercase;letter-spacing:.5px;margin-top:10px}
|
||||||
|
|
||||||
|
/* ── Changelog / decision list ─── */
|
||||||
|
.CHANGES{background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:20px 24px;margin-bottom:40px}
|
||||||
|
.CHANGES h2{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:14px;padding-bottom:10px;border-bottom:1px solid #E8E4DF}
|
||||||
|
.CHANGES-GRID{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||||
|
.C-COL h3{font-size:10px;font-weight:800;color:#444;margin-bottom:8px}
|
||||||
|
.C-COL ul{list-style:none;display:flex;flex-direction:column;gap:5px}
|
||||||
|
.C-COL ul li{font-size:11px;color:#555;padding-left:16px;position:relative;line-height:1.5}
|
||||||
|
.C-COL.new li::before{content:'✦';position:absolute;left:0;color:#012851;font-size:8px}
|
||||||
|
.C-COL.remove li::before{content:'✗';position:absolute;left:0;color:#DC2626}
|
||||||
|
.C-COL.keep li::before{content:'→';position:absolute;left:0;color:#888}
|
||||||
|
|
||||||
|
/* ── Impl notes ─── */
|
||||||
|
.IMPL{background:#0D2240;border-radius:8px;padding:20px 24px;margin-top:48px}
|
||||||
|
.IMPL h2{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:rgba(255,255,255,.4);margin-bottom:16px;padding-bottom:10px;border-bottom:1px solid rgba(255,255,255,.08)}
|
||||||
|
.IMPL-GRID{display:grid;grid-template-columns:1fr 1fr 1fr;gap:16px}
|
||||||
|
.IMPL-COL h3{font-size:9.5px;font-weight:800;color:rgba(255,255,255,.6);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}
|
||||||
|
.IMPL-COL ul{list-style:none;display:flex;flex-direction:column;gap:5px}
|
||||||
|
.IMPL-COL ul li{font-size:10.5px;color:rgba(255,255,255,.75);padding-left:14px;position:relative;line-height:1.5}
|
||||||
|
.IMPL-COL ul li::before{content:'›';position:absolute;left:0;color:rgba(255,255,255,.3)}
|
||||||
|
.IMPL-COL code{font-family:monospace;font-size:9.5px;background:rgba(255,255,255,.08);padding:1px 4px;border-radius:3px;color:#A1DCD8}
|
||||||
|
|
||||||
|
/* ── Dark mode simulation ─── */
|
||||||
|
.DK .N{background:#012851} /* same! brand constant */
|
||||||
|
.DK .nl{color:rgba(255,255,255,.55)}
|
||||||
|
.DK .nl.on{color:#fff}
|
||||||
|
.DK-MAIN{background:#1A1A1A;padding:14px 18px;min-height:60px}
|
||||||
|
.DK-PH{height:7px;background:#2A2A2A;border-radius:2px;margin-bottom:4px}
|
||||||
|
|
||||||
|
/* ── Measurement annotation ─── */
|
||||||
|
.MEA{display:flex;align-items:center;gap:4px;font-size:7.5px;font-weight:700;color:#6B7280;margin-top:8px}
|
||||||
|
.MEA-LINE{flex:1;height:1px;border-top:1px dashed #C8C4BE}
|
||||||
|
.MEA-VAL{background:#E8E4DF;padding:1px 6px;border-radius:3px;white-space:nowrap}
|
||||||
|
.token{font-family:monospace;font-size:8.5px;background:#F0EFE9;border:1px solid #D8D4CE;padding:1px 5px;border-radius:3px;color:#012851}
|
||||||
|
|
||||||
|
/* ── Color swatch ─── */
|
||||||
|
.SW{display:flex;flex-direction:column;align-items:flex-start;gap:3px}
|
||||||
|
.SW-BOX{width:36px;height:20px;border-radius:3px;border:1px solid rgba(0,0,0,.1)}
|
||||||
|
.SW-NAME{font-size:7.5px;font-weight:700;color:#444}
|
||||||
|
.SW-HEX{font-size:7px;color:#888;font-family:monospace}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
MASTHEAD
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="mast">
|
||||||
|
<div class="mast-top">
|
||||||
|
<div>
|
||||||
|
<h1>Header / Navigation Redesign</h1>
|
||||||
|
<p>Full header redesign: brand-navy bar, 4px purple accent strip, always-visible logo on mobile, high-contrast nav states, dark-mode as brand constant, and integrated login header. Replaces the current white <code style="font-family:monospace;font-size:10px;background:rgba(255,255,255,.08);padding:1px 4px;border-radius:3px;color:#A1DCD8">bg-surface</code> header that leaks the semantic surface color into what should be a brand-constant element.</p>
|
||||||
|
</div>
|
||||||
|
<span class="mast-badge mb-draft">Draft · 2026-03-30</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size:8.5px;color:rgba(255,255,255,.3);margin-bottom:12px">Leonie Voss · Senior UX Designer</div>
|
||||||
|
<div class="decisions">
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Header background</div>
|
||||||
|
<div class="dec-value"><s>bg-surface (#fff)</s><br>→ brand-navy #012851</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Top accent strip</div>
|
||||||
|
<div class="dec-value"><s>None</s><br>→ 4px · brand-purple #B4B9FF</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Active nav state</div>
|
||||||
|
<div class="dec-value"><s>rgba purple pill (~1.08:1)</s><br>→ white + mint underline</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Mobile logo</div>
|
||||||
|
<div class="dec-value"><s>Hidden</s><br>→ Always visible, left side</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Dark mode header</div>
|
||||||
|
<div class="dec-value"><s>Flips to #1a1a1a</s><br>→ Stays brand-navy (constant)</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Login page header</div>
|
||||||
|
<div class="dec-value"><s>Hidden entirely</s><br>→ Brand header, logo-only</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Language switcher (login)</div>
|
||||||
|
<div class="dec-value"><s>Floating, no context</s><br>→ Integrated in login header right</div>
|
||||||
|
</div>
|
||||||
|
<div class="dec">
|
||||||
|
<div class="dec-label">Total header height</div>
|
||||||
|
<div class="dec-value">4px strip + 64px bar<br>= 68px total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
CHANGES SUMMARY
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="CHANGES">
|
||||||
|
<h2>What changes vs. current implementation</h2>
|
||||||
|
<div class="CHANGES-GRID">
|
||||||
|
<div class="C-COL new">
|
||||||
|
<h3>New / changed</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Header <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">bg-surface</code> → fixed <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">bg-brand-navy</code> (#012851) — not theme-aware</li>
|
||||||
|
<li>4px accent strip above header: <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">background: #B4B9FF</code></li>
|
||||||
|
<li>Nav link colors on navy: inactive 55% white, hover 85% white, active 100% white</li>
|
||||||
|
<li>Active indicator: 2px bottom border in brand-mint (#A1DCD8) instead of rgba purple pill</li>
|
||||||
|
<li>Mobile: logo always visible left; hamburger icon white (was hidden or missing)</li>
|
||||||
|
<li>User avatar: mint background (#A1DCD8) with navy text (#012851)</li>
|
||||||
|
<li>Dark mode: <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">dark:bg-surface</code> override removed from header — stays navy</li>
|
||||||
|
<li>Login page: <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">isAuthPage</code> guard changed — shows logo-only header, not <code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">null</code></li>
|
||||||
|
<li>Language switcher on login: moved into header right slot</li>
|
||||||
|
<li>Mobile drawer: opens below navy header, white background, navy text links, mint active indicator</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="C-COL keep">
|
||||||
|
<h3>Kept unchanged</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">AppNav</code> component structure — only CSS changes</li>
|
||||||
|
<li>Sticky header behavior (<code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">sticky top-0 z-50</code>)</li>
|
||||||
|
<li>Max-width container and horizontal padding</li>
|
||||||
|
<li>NotificationBell, ThemeToggle, LanguageSwitcher components — only icon color changes</li>
|
||||||
|
<li>UserMenu component — only avatar color changes</li>
|
||||||
|
<li>Mobile drawer open/close logic</li>
|
||||||
|
<li>Admin nav link conditional visibility</li>
|
||||||
|
<li><code style="font-size:9px;background:#F0EFE9;padding:1px 4px;border-radius:2px">isAuthPage</code> derived value — still used, just different output</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 1 — CURRENT STATE
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">1</span> Current state — problems annotated</div>
|
||||||
|
|
||||||
|
<div class="sg sg-2a">
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Desktop <span class="sz">≥768px</span> <span class="state st-bad">6 issues</span></div>
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/dokumente</span></div></div>
|
||||||
|
<!-- Current broken header -->
|
||||||
|
<div class="H-OLD">
|
||||||
|
<!-- issue 1: white bg, no strip -->
|
||||||
|
<div style="position:absolute;top:0;left:0;right:0;height:2px;background:#FDBA74;opacity:.3"></div>
|
||||||
|
<span class="H-OLD-LOGO">FAMILIENARCHIV</span>
|
||||||
|
<div class="H-OLD-NAV">
|
||||||
|
<span class="H-OLD-LINK act">Dokumente</span>
|
||||||
|
<span class="H-OLD-LINK">Personen</span>
|
||||||
|
<span class="H-OLD-LINK">Korrespondenz</span>
|
||||||
|
</div>
|
||||||
|
<div class="H-OLD-R">
|
||||||
|
<span style="font-size:7px;color:#6B7280;font-weight:700">DE</span>
|
||||||
|
<div class="H-OLD-ICO"></div>
|
||||||
|
<div class="H-OLD-ICO"></div>
|
||||||
|
<div class="H-OLD-AV">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN"><div class="PH w80"></div><div class="PH w60"></div><div class="PH w70"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block">
|
||||||
|
<strong>Issues — desktop</strong>
|
||||||
|
<ol>
|
||||||
|
<li><strong>①</strong> Background is <code style="font-size:9px;background:#FFF0E8;padding:1px 3px;border-radius:2px">bg-surface</code> (white) — not brand. Every other archival app in this family uses a dark branded header.</li>
|
||||||
|
<li><strong>②</strong> No 4px accent strip at top — missing the canonical brand-purple cap.</li>
|
||||||
|
<li><strong>③</strong> Active link "Dokumente" uses <code style="font-size:9px;background:#FFF0E8;padding:1px 3px;border-radius:2px">rgba(180,185,255,0.15)</code> on white = contrast ~1.08:1. Completely invisible. WCAG AA minimum is 3:1 for UI components.</li>
|
||||||
|
<li><strong>④</strong> Logo is navy-on-white — works in light mode but will disappear in dark mode if header ever inherits <code style="font-size:9px;background:#FFF0E8;padding:1px 3px;border-radius:2px">#1a1a1a</code>.</li>
|
||||||
|
<li><strong>⑤</strong> Dark mode: header flips to near-black (#1a1a1a) — breaks brand consistency. Header should be a brand constant, not a semantic surface.</li>
|
||||||
|
<li><strong>⑥</strong> User avatar: dark navy circle blends with any dark-mode context and provides no semantic meaning via color.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Mobile <span class="sz">375px</span> <span class="state st-bad">logo missing</span></div>
|
||||||
|
<div style="display:flex;justify-content:center">
|
||||||
|
<div class="WF-M">
|
||||||
|
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
|
||||||
|
<div class="N-M-OLD">
|
||||||
|
<!-- No logo! Just hamburger. -->
|
||||||
|
<div style="width:18px;display:flex;flex-direction:column;gap:3px">
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px"></div>
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px"></div>
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="H-OLD-ICO" style="width:20px;height:20px"></div>
|
||||||
|
<div class="H-OLD-AV">LV</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN" style="padding:10px 12px;min-height:60px">
|
||||||
|
<div class="PH w80"></div><div class="PH w60"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block" style="margin-top:8px">
|
||||||
|
<strong>Mobile issues</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Logo hidden on mobile — brand identity completely lost</li>
|
||||||
|
<li>Hamburger icon is dark on white — fine in light mode, breaks in dark</li>
|
||||||
|
<li>Header still white — same surface-color problem as desktop</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 2 — PROPOSED DESKTOP
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">2</span> Proposed redesign — Desktop</div>
|
||||||
|
|
||||||
|
<div class="sg sg-2" style="margin-bottom:24px">
|
||||||
|
|
||||||
|
<!-- Light mode -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Light mode <span class="sz">≥768px</span> <span class="state st-good">Proposed</span></div>
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/korrespondenz</span></div></div>
|
||||||
|
<div class="STRIP"></div>
|
||||||
|
<div class="N">
|
||||||
|
<span class="logo">FAMILIENARCHIV</span>
|
||||||
|
<div style="width:1px;height:16px;background:rgba(255,255,255,.15);margin:0 2px"></div>
|
||||||
|
<span class="nl">Dokumente</span>
|
||||||
|
<span class="nl">Personen</span>
|
||||||
|
<span class="nl on">Korrespondenz</span>
|
||||||
|
<div class="nr">
|
||||||
|
<span class="nico-lbl">DE</span>
|
||||||
|
<div class="nico" style="background:rgba(255,255,255,.12)"></div><!-- theme toggle -->
|
||||||
|
<div class="nico" style="background:rgba(255,255,255,.12)"></div><!-- bell -->
|
||||||
|
<div class="nico-av">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN"><div class="PH w80"></div><div class="PH w60"></div><div class="PH w70"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="MEA">
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
<span class="MEA-VAL">4px accent strip · background: #B4B9FF</span>
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
</div>
|
||||||
|
<div class="MEA">
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
<span class="MEA-VAL">64px nav bar · background: #012851</span>
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
</div>
|
||||||
|
<div class="MEA">
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
<span class="MEA-VAL">Total: 68px</span>
|
||||||
|
<div class="MEA-LINE"></div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Active link: "Korrespondenz" — white text + 2px bottom border in #A1DCD8. Divider between logo and nav: rgba(255,255,255,0.15). Avatar: mint bg + navy text.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dark mode -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Dark mode <span class="sz">≥768px</span> <span class="state st-good">Same navy — brand constant</span></div>
|
||||||
|
<div class="wf" style="border-color:#444">
|
||||||
|
<div class="wf-bar" style="background:#333;border-color:#444"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar" style="background:#444"><span style="color:#aaa">/korrespondenz</span></div></div>
|
||||||
|
<div class="STRIP"></div><!-- strip is identical — not theme-aware -->
|
||||||
|
<div class="N"><!-- N stays the same #012851 in dark mode too -->
|
||||||
|
<span class="logo">FAMILIENARCHIV</span>
|
||||||
|
<div style="width:1px;height:16px;background:rgba(255,255,255,.15);margin:0 2px"></div>
|
||||||
|
<span class="nl">Dokumente</span>
|
||||||
|
<span class="nl">Personen</span>
|
||||||
|
<span class="nl on">Korrespondenz</span>
|
||||||
|
<div class="nr">
|
||||||
|
<span class="nico-lbl">DE</span>
|
||||||
|
<div class="nico"></div>
|
||||||
|
<div class="nico"></div>
|
||||||
|
<div class="nico-av">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="DK-MAIN"><div class="DK-PH w80"></div><div class="DK-PH w60"></div><div class="DK-PH w70"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block" style="background:#EFF6FF;border-color:#BFDBFE;color:#1E40AF;margin-top:8px">
|
||||||
|
<strong>Dark mode rule:</strong> The header is a brand element, not a semantic surface. It does NOT respond to the <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">dark:</code> variant. Page content behind it switches; the header stays #012851.
|
||||||
|
<ul style="margin-top:4px">
|
||||||
|
<li>Remove <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">dark:bg-surface</code> from header element</li>
|
||||||
|
<li>Apply <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">bg-brand-navy</code> as a non-dark-variant class</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Element key -->
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:16px 20px;margin-top:8px">
|
||||||
|
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:12px">Element color tokens</div>
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(6,1fr);gap:16px">
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:#012851"></div>
|
||||||
|
<div class="SW-NAME">brand-navy</div>
|
||||||
|
<div class="SW-HEX">#012851</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Header bg</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:#B4B9FF"></div>
|
||||||
|
<div class="SW-NAME">brand-purple</div>
|
||||||
|
<div class="SW-HEX">#B4B9FF</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Accent strip</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:#A1DCD8"></div>
|
||||||
|
<div class="SW-NAME">brand-mint</div>
|
||||||
|
<div class="SW-HEX">#A1DCD8</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Active underline · avatar bg</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:#ffffff;border-color:#D0D0D0"></div>
|
||||||
|
<div class="SW-NAME">white</div>
|
||||||
|
<div class="SW-HEX">#ffffff</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Active link text · logo</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:rgba(255,255,255,0.55)"></div>
|
||||||
|
<div class="SW-NAME">white/55</div>
|
||||||
|
<div class="SW-HEX">rgba(255,255,255,.55)</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Inactive nav links</div>
|
||||||
|
</div>
|
||||||
|
<div class="SW">
|
||||||
|
<div class="SW-BOX" style="background:rgba(255,255,255,0.85)"></div>
|
||||||
|
<div class="SW-NAME">white/85</div>
|
||||||
|
<div class="SW-HEX">rgba(255,255,255,.85)</div>
|
||||||
|
<div style="font-size:7px;color:#888;margin-top:2px">Hover nav links</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 3 — NAV STATES
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">3</span> Nav link states</div>
|
||||||
|
|
||||||
|
<div class="NSG" style="margin-bottom:20px">
|
||||||
|
|
||||||
|
<!-- Inactive -->
|
||||||
|
<div class="NS">
|
||||||
|
<div class="NS-LABEL">Inactive</div>
|
||||||
|
<div class="NS-DEMO">
|
||||||
|
<span class="NS-LINK NS-INACTIVE">Personen</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
|
||||||
|
<div><span class="token">color: rgba(255,255,255,.55)</span></div>
|
||||||
|
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="contrast-badge cr-pass">4.9:1 ✓ AA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Intentionally muted — communicates "not here yet" without removing affordance.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hover -->
|
||||||
|
<div class="NS">
|
||||||
|
<div class="NS-LABEL">Hover</div>
|
||||||
|
<div class="NS-DEMO">
|
||||||
|
<span class="NS-LINK NS-HOVER">Personen</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
|
||||||
|
<div><span class="token">color: rgba(255,255,255,.85)</span></div>
|
||||||
|
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="contrast-badge cr-pass">7.8:1 ✓ AA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Smooth brightness step on hover. Transition: <code style="font-size:9px">color 150ms ease</code>.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active -->
|
||||||
|
<div class="NS">
|
||||||
|
<div class="NS-LABEL">Active (current page)</div>
|
||||||
|
<div class="NS-DEMO">
|
||||||
|
<span class="NS-LINK NS-ACTIVE">Korrespondenz</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
|
||||||
|
<div><span class="token">color: #ffffff</span></div>
|
||||||
|
<div><span class="token">border-bottom: 2px solid #A1DCD8</span></div>
|
||||||
|
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="contrast-badge cr-pass">21:1 ✓ AAA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Mint underline is the active indicator — not a background pill. Clear, low-weight, distinct from hover.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Focus -->
|
||||||
|
<div class="NS">
|
||||||
|
<div class="NS-LABEL">Focus (keyboard)</div>
|
||||||
|
<div class="NS-DEMO">
|
||||||
|
<span class="NS-LINK NS-FOCUS">Personen</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:6px;font-size:9px;color:#555;line-height:1.6">
|
||||||
|
<div><span class="token">outline: 2px solid #A1DCD8</span></div>
|
||||||
|
<div><span class="token">outline-offset: 3px</span></div>
|
||||||
|
<div><span class="token">border-radius: 2px</span></div>
|
||||||
|
<div style="margin-top:4px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="contrast-badge cr-pass">3.4:1 ✓ AA</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Mint outline on navy — meets WCAG 3:1 focus indicator requirement. Never suppress outline.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Before/After contrast comparison -->
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:8px;padding:16px 20px">
|
||||||
|
<div style="font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:.8px;color:#888;margin-bottom:12px">Active state contrast — before vs. after</div>
|
||||||
|
<div class="sg sg-2">
|
||||||
|
<div>
|
||||||
|
<div class="sl" style="margin-bottom:8px">Before <span class="state st-bad">Fails WCAG</span></div>
|
||||||
|
<div style="background:#ffffff;border-radius:5px;padding:10px 14px;border:1.5px solid #E5E7EB;display:inline-flex;align-items:center;gap:0">
|
||||||
|
<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#012851;background:rgba(180,185,255,0.15);padding:4px 10px;border-radius:4px">Dokumente</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;font-size:9px;color:#555">
|
||||||
|
Navy text (#012851) on rgba(180,185,255,0.15) on white.<br>
|
||||||
|
Effective background: approx. #F4F4FF.<br>
|
||||||
|
<span class="contrast-badge cr-fail" style="margin-top:4px">~1.08:1 ✗ Fail</span>
|
||||||
|
</div>
|
||||||
|
<div class="sc">The active pill is invisible. Users can't tell which page they're on.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="sl" style="margin-bottom:8px">After <span class="state st-good">Passes WCAG AAA</span></div>
|
||||||
|
<div style="background:#012851;border-radius:5px;padding:10px 14px;display:inline-flex;align-items:center">
|
||||||
|
<span style="font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#fff;border-bottom:2px solid #A1DCD8;padding-bottom:2px">Dokumente</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;font-size:9px;color:#555">
|
||||||
|
White text (#ffffff) on navy (#012851).<br>
|
||||||
|
Mint underline: #A1DCD8 on navy = 3.1:1 for the indicator itself.<br>
|
||||||
|
<span class="contrast-badge cr-pass" style="margin-top:4px">21:1 ✓ AAA (text)</span>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Unambiguous. The underline echoes brand-mint used elsewhere as an accent.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 4 — MOBILE
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">4</span> Mobile header + nav drawer</div>
|
||||||
|
|
||||||
|
<div class="sg sg-3" style="align-items:start">
|
||||||
|
|
||||||
|
<!-- Current mobile (broken) -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Current <span class="state st-bad">No logo</span></div>
|
||||||
|
<div style="display:flex;justify-content:center">
|
||||||
|
<div class="WF-M">
|
||||||
|
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
|
||||||
|
<div class="N-M-OLD">
|
||||||
|
<div style="width:18px;display:flex;flex-direction:column;gap:3px">
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px;width:100%"></div>
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px;width:100%"></div>
|
||||||
|
<div style="height:1.5px;background:#012851;border-radius:1px;width:100%"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-left:auto;display:flex;gap:5px;align-items:center">
|
||||||
|
<div class="H-OLD-ICO" style="width:18px;height:18px"></div>
|
||||||
|
<div class="H-OLD-AV" style="width:20px;height:20px">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN" style="padding:10px 12px;min-height:60px"><div class="PH w80"></div><div class="PH w60"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block" style="margin-top:8px">
|
||||||
|
<strong>Problem:</strong> No logo. The user has zero brand context. On first load, there is no visual cue that this is Familienarchiv. The hamburger icon color (dark navy) will also break in dark mode.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proposed mobile header -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Proposed header <span class="state st-good">Logo visible</span></div>
|
||||||
|
<div style="display:flex;justify-content:center">
|
||||||
|
<div class="WF-M">
|
||||||
|
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
|
||||||
|
<div style="height:3px;background:#B4B9FF"></div><!-- accent strip, thinner on mobile -->
|
||||||
|
<div class="N-M" style="background:#012851">
|
||||||
|
<span class="logo" style="font-size:7.5px;letter-spacing:1px">FAMILIENARCHIV</span>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<div class="nico-av" style="width:20px;height:20px;font-size:5.5px">LV</div>
|
||||||
|
<div class="HAMBURGER">
|
||||||
|
<div class="HAMBURGER-LINE"></div>
|
||||||
|
<div class="HAMBURGER-LINE"></div>
|
||||||
|
<div class="HAMBURGER-LINE"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN" style="padding:10px 12px;min-height:60px"><div class="PH w80"></div><div class="PH w60"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Logo always visible left. Avatar + hamburger right. Accent strip is 3px on mobile (saves 1px). Background is brand-navy — no theme variation.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proposed drawer open -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Nav drawer <span class="state st-good">Open state</span></div>
|
||||||
|
<div style="display:flex;justify-content:center">
|
||||||
|
<div class="WF-M">
|
||||||
|
<div class="WF-M-STATUS"><span class="WF-M-TIME">09:41</span><div class="WF-M-ICONS"><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div><div class="WF-M-ICON"></div></div></div>
|
||||||
|
<div style="height:3px;background:#B4B9FF"></div>
|
||||||
|
<div class="N-M" style="background:#012851">
|
||||||
|
<span class="logo" style="font-size:7.5px;letter-spacing:1px">FAMILIENARCHIV</span>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<div class="nico-av" style="width:20px;height:20px;font-size:5.5px">LV</div>
|
||||||
|
<!-- X icon when drawer open -->
|
||||||
|
<div style="width:18px;height:18px;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,.85);font-size:13px;font-weight:300">✕</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Drawer -->
|
||||||
|
<div class="DRAWER">
|
||||||
|
<div class="DRAWER-LINK off">Dokumente</div>
|
||||||
|
<div class="DRAWER-LINK off">Personen</div>
|
||||||
|
<div class="DRAWER-LINK on">Korrespondenz</div>
|
||||||
|
<div class="DRAWER-LINK off" style="font-size:7px;color:rgba(1,40,81,.4)">Admin</div>
|
||||||
|
<div class="DRAWER-DIV"></div>
|
||||||
|
<div class="DRAWER-LANG">
|
||||||
|
<span style="font-size:7px;color:#888;font-weight:700;text-transform:uppercase;letter-spacing:.5px;margin-right:4px">Sprache</span>
|
||||||
|
<div class="DRAWER-LANG-BTN on">DE</div>
|
||||||
|
<div class="DRAWER-LANG-BTN">EN</div>
|
||||||
|
<div class="DRAWER-LANG-BTN">ES</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="MAIN" style="padding:10px 12px;min-height:40px;opacity:.4"><div class="PH w80"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Drawer uses white background with navy text — intentional reversal of the dark header. Active page: mint left border + sand background. Language switcher lives in drawer on mobile (not floating).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 5 — LOGIN PAGE
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">5</span> Login page — branded header</div>
|
||||||
|
|
||||||
|
<div class="sg sg-2">
|
||||||
|
|
||||||
|
<!-- Current login (no header) -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Current — header hidden <span class="state st-bad">No brand context</span></div>
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/login</span></div></div>
|
||||||
|
<!-- Floating language switcher (no context) -->
|
||||||
|
<div style="position:relative;min-height:140px;background:#F0EFE9">
|
||||||
|
<div style="position:absolute;top:8px;right:10px;display:flex;gap:4px">
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:#012851;background:#fff;border:1.5px solid #D1D5DB;padding:2px 7px;border-radius:3px">DE</span>
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:#888;padding:2px 7px;border-radius:3px">EN</span>
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:#888;padding:2px 7px;border-radius:3px">ES</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;padding-top:22px">
|
||||||
|
<div class="LOGIN-CARD">
|
||||||
|
<div class="LOGIN-CARD-TITLE">Anmelden</div>
|
||||||
|
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">E-Mail</div><div class="LOGIN-FIELD-I"></div></div>
|
||||||
|
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">Passwort</div><div class="LOGIN-FIELD-I"></div></div>
|
||||||
|
<div class="LOGIN-BTN">Anmelden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ann-block">
|
||||||
|
<strong>Problems:</strong> Header is hidden entirely on auth pages. Language switcher floats top-right with no visual anchor — it's a ghost. Users arrive with zero brand context. The page could be any app.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proposed login (branded header) -->
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sl">Proposed — logo-only header <span class="state st-good">Branded</span></div>
|
||||||
|
<div class="wf">
|
||||||
|
<div class="wf-bar"><div class="dot r"></div><div class="dot y"></div><div class="dot g"></div><div class="urlbar"><span>/login</span></div></div>
|
||||||
|
<div class="LOGIN-BG">
|
||||||
|
<div class="LOGIN-HEADER"></div><!-- 4px purple strip -->
|
||||||
|
<div class="LOGIN-NAV">
|
||||||
|
<span class="logo">FAMILIENARCHIV</span>
|
||||||
|
<!-- Right: language switcher integrated in header -->
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
<span style="font-size:7.5px;font-weight:800;color:#fff;opacity:.9">DE</span>
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:rgba(255,255,255,.5)">EN</span>
|
||||||
|
<span style="font-size:7.5px;font-weight:700;color:rgba(255,255,255,.5)">ES</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="LOGIN-CARD">
|
||||||
|
<div class="LOGIN-CARD-TITLE">Anmelden</div>
|
||||||
|
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">E-Mail</div><div class="LOGIN-FIELD-I"></div></div>
|
||||||
|
<div class="LOGIN-FIELD"><div class="LOGIN-FIELD-L">Passwort</div><div class="LOGIN-FIELD-I"></div></div>
|
||||||
|
<div class="LOGIN-BTN">Anmelden</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sc">Accent strip + navy header appears on login. No nav links (user is not authenticated). Language switcher lives in header right slot — same position as desktop, consistent muscle memory. The brand is present from the first moment the user sees the app.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Code change note -->
|
||||||
|
<div class="ann-block" style="background:#EFF6FF;border-color:#BFDBFE;color:#1E40AF;margin-top:16px">
|
||||||
|
<strong>Implementation change:</strong> In <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">+layout.svelte</code>, the <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">{#if !isAuthPage}</code> guard currently hides the entire header. Replace with a conditional that renders a <em>login variant</em> of the header (logo + lang switcher, no nav links) when <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">isAuthPage</code> is true. Move the <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">LanguageSwitcher</code> import into the header for the auth variant. Remove the floating <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">LanguageSwitcher</code> from <code style="font-size:9px;background:rgba(30,64,175,.1);padding:1px 3px;border-radius:2px">/login/+page.svelte</code>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
SECTION 6 — RIGHT UTILITIES DETAIL
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="sec">
|
||||||
|
<div class="sec-h"><span class="sec-num">6</span> Right utility area — element by element</div>
|
||||||
|
|
||||||
|
<div style="background:#012851;border-radius:8px;padding:16px 20px;margin-bottom:16px">
|
||||||
|
<div style="height:40px;display:flex;align-items:center;gap:10px;justify-content:flex-end">
|
||||||
|
<!-- Language switcher -->
|
||||||
|
<div style="display:flex;gap:5px;align-items:center;border-right:1px solid rgba(255,255,255,.15);padding-right:10px">
|
||||||
|
<span style="font-size:8px;font-weight:800;color:#fff">DE</span>
|
||||||
|
<span style="font-size:8px;font-weight:700;color:rgba(255,255,255,.5)">EN</span>
|
||||||
|
<span style="font-size:8px;font-weight:700;color:rgba(255,255,255,.5)">ES</span>
|
||||||
|
</div>
|
||||||
|
<!-- Theme toggle -->
|
||||||
|
<div style="width:24px;height:24px;background:rgba(255,255,255,.1);border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:12px;opacity:.7">☀</div>
|
||||||
|
<!-- Notification bell -->
|
||||||
|
<div style="position:relative;width:24px;height:24px;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="font-size:14px;color:rgba(255,255,255,.75)">🔔</span>
|
||||||
|
<div style="position:absolute;top:3px;right:2px;width:7px;height:7px;background:#EF4444;border-radius:50%;border:1.5px solid #012851"></div>
|
||||||
|
</div>
|
||||||
|
<!-- User avatar -->
|
||||||
|
<div class="nico-av">LV</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px">
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
|
||||||
|
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">Language switcher</div>
|
||||||
|
<div style="font-size:9px;color:#444;line-height:1.6">
|
||||||
|
Active lang: <span class="token">color: #ffffff</span><br>
|
||||||
|
Inactive lang: <span class="token">color: rgba(255,255,255,.5)</span><br>
|
||||||
|
Separator from rest: <span class="token">border-right: 1px solid rgba(255,255,255,.15)</span><br>
|
||||||
|
On login: visible in header right slot
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
|
||||||
|
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">Theme toggle</div>
|
||||||
|
<div style="font-size:9px;color:#444;line-height:1.6">
|
||||||
|
Icon: white at <span class="token">opacity: 0.7</span><br>
|
||||||
|
Hover: <span class="token">opacity: 1.0</span><br>
|
||||||
|
Background: <span class="token">rgba(255,255,255,.1)</span><br>
|
||||||
|
No change to toggle logic — icon color only
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
|
||||||
|
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">Notification bell</div>
|
||||||
|
<div style="font-size:9px;color:#444;line-height:1.6">
|
||||||
|
Icon: white at <span class="token">opacity: 0.75</span><br>
|
||||||
|
Badge: stays <span class="token">bg-red-500</span> (#EF4444)<br>
|
||||||
|
Badge border: <span class="token">border: 1.5px solid #012851</span> (halos on navy)<br>
|
||||||
|
No component logic changes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1.5px solid #E0DDD6;border-radius:6px;padding:12px">
|
||||||
|
<div style="font-size:8px;font-weight:800;text-transform:uppercase;letter-spacing:.5px;color:#888;margin-bottom:8px">User avatar</div>
|
||||||
|
<div style="font-size:9px;color:#444;line-height:1.6">
|
||||||
|
Background: <span class="token">#A1DCD8</span> (brand-mint)<br>
|
||||||
|
Text: <span class="token">#012851</span> (brand-navy)<br>
|
||||||
|
Contrast: 4.8:1 ✓ AA<br>
|
||||||
|
Replaces navy bg (dark-on-dark in dark mode)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════
|
||||||
|
IMPLEMENTATION NOTES
|
||||||
|
══════════════════════════════════ -->
|
||||||
|
<div class="IMPL">
|
||||||
|
<h2>Implementation notes</h2>
|
||||||
|
<div class="IMPL-GRID">
|
||||||
|
<div class="IMPL-COL">
|
||||||
|
<h3>CSS / Tailwind changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Header: replace <code>bg-surface</code> with <code>bg-[#012851]</code> (or add a <code>bg-brand-navy</code> utility to <code>layout.css</code>)</li>
|
||||||
|
<li>Remove <code>border-b border-line-2</code> from header — the accent strip replaces the visual separator</li>
|
||||||
|
<li>Add a <code><div class="h-1 bg-[#B4B9FF]"></code> before the nav bar in <code>+layout.svelte</code></li>
|
||||||
|
<li>Nav links: replace <code>text-ink</code> + <code>bg-nav-active</code> with opacity-based white utilities: <code>text-white/55</code> inactive, <code>hover:text-white/85</code>, <code>text-white border-b-2 border-[#A1DCD8]</code> active</li>
|
||||||
|
<li>User avatar: swap <code>bg-brand-navy text-white</code> → <code>bg-[#A1DCD8] text-[#012851]</code></li>
|
||||||
|
<li>Notification badge: add <code>border-2 border-[#012851]</code> to badge element</li>
|
||||||
|
<li>Dark mode: on the <code><header></code> element, ensure there is NO <code>dark:</code> variant overriding the background</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="IMPL-COL">
|
||||||
|
<h3>Component changes</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>+layout.svelte</code>: split the <code>{#if !isAuthPage}</code> guard into two branches — full header (authed) vs. login header (logo + lang only)</li>
|
||||||
|
<li><code>AppNav.svelte</code>: ensure logo is always rendered, not hidden on mobile via <code>hidden sm:flex</code> or similar</li>
|
||||||
|
<li><code>AppNav.svelte</code>: hamburger button — icon color from dark to <code>text-white/85</code></li>
|
||||||
|
<li><code>AppNav.svelte</code>: active link class — remove <code>bg-nav-active</code>, add bottom border in mint</li>
|
||||||
|
<li><code>UserMenu.svelte</code>: avatar background and text color</li>
|
||||||
|
<li><code>ThemeToggle.svelte</code>: icon fill/stroke → <code>text-white/70</code></li>
|
||||||
|
<li><code>NotificationBell.svelte</code>: icon color → <code>text-white/75</code></li>
|
||||||
|
<li><code>/login/+page.svelte</code>: remove standalone <code><LanguageSwitcher></code> — it moves to the layout header</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="IMPL-COL">
|
||||||
|
<h3>CSS variable candidates</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Consider adding to <code>layout.css</code>:<br><code>--header-bg: #012851;</code><br><code>--header-accent: #B4B9FF;</code><br><code>--header-nav-active: #A1DCD8;</code></li>
|
||||||
|
<li>These are intentionally NOT in the dark-mode <code>@media (prefers-color-scheme: dark)</code> block — they are brand constants</li>
|
||||||
|
<li>If Tailwind 4 theme is configured, add:<br><code>brand-navy: #012851</code><br><code>brand-purple: #B4B9FF</code><br><code>brand-mint: #A1DCD8</code><br>to the <code>@theme</code> block in <code>layout.css</code></li>
|
||||||
|
<li>No backend changes required</li>
|
||||||
|
<li>No i18n key changes required</li>
|
||||||
|
<li>No new routes required</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1645
docs/specs/persons-concept-a-enriched-directory.html
Normal file
1645
docs/specs/persons-concept-a-enriched-directory.html
Normal file
File diff suppressed because it is too large
Load Diff
804
docs/specs/transcription-read-mode-final-spec.html
Normal file
804
docs/specs/transcription-read-mode-final-spec.html
Normal file
@@ -0,0 +1,804 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Transcription Read Mode — Final Spec (Clean Split)</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500&family=DM+Sans:wght@300;400;500;600&family=DM+Mono:wght@400;500&family=Tinos:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet"/>
|
||||||
|
<style>
|
||||||
|
:root{--color-page:#FAFAF7;--color-surface:#F5F4EE;--color-subtle:#EDECEA;--color-border:#D8D7D0;--color-text-muted:#6B6A63;--color-text:#1C1C18;--navy:#012851;--mint:#A1DCD8;--sand:#F0EFE9;--turquoise:#00C7B1;--accent-bg:rgba(161,220,216,.12);--blue-tint:#E6F1FB;--blue:#2D7DD2;--blue-dark:#185FA5;--green-tint:#E8F5EA;--green:#3D8C4A;--green-dark:#2E6E39;--orange-tint:#FEF0E6;--orange:#E8862A;--orange-dark:#B46820;--color-error:#DC4C3E;--font-display:'Fraunces',Georgia,serif;--font-sans:'DM Sans',system-ui,sans-serif;--font-mono:'DM Mono',monospace;--font-read:'Tinos',Georgia,serif;--radius-sm:4px;--radius-md:6px;--radius-lg:10px;--radius-xl:16px;--shadow-card:0 1px 3px rgba(28,28,24,.06),0 1px 2px rgba(28,28,24,.04);--shadow-raised:0 4px 12px rgba(28,28,24,.08),0 2px 4px rgba(28,28,24,.04);--shadow-overlay:0 8px 32px rgba(28,28,24,.12),0 2px 8px rgba(28,28,24,.06);}
|
||||||
|
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;}
|
||||||
|
body{font-family:var(--font-sans);background:#E8E7E2;color:var(--color-text);font-size:14px;line-height:1.6;}
|
||||||
|
.doc{max-width:1200px;margin:0 auto;padding:48px 40px 120px;}
|
||||||
|
|
||||||
|
.doc-header{display:flex;justify-content:space-between;align-items:flex-end;padding-bottom:28px;border-bottom:1px solid var(--color-border);margin-bottom:48px;background:var(--color-page);margin:-48px -40px 48px;padding:48px 40px 28px;border-radius:var(--radius-xl) var(--radius-xl) 0 0;}
|
||||||
|
.doc-header h1{font-family:var(--font-display);font-size:28px;font-weight:500;letter-spacing:-.02em;margin-bottom:4px;}
|
||||||
|
.doc-header p{font-size:13px;color:var(--color-text-muted);max-width:680px;}
|
||||||
|
.doc-meta{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);text-align:right;line-height:1.9;}
|
||||||
|
.pill{display:inline-block;padding:2px 8px;border-radius:var(--radius-sm);font-size:10px;font-weight:500;letter-spacing:.05em;}
|
||||||
|
.pill-g{background:var(--green-tint);color:var(--green-dark);}
|
||||||
|
|
||||||
|
.section{margin-bottom:64px;}
|
||||||
|
.section-title{font-size:10px;font-weight:500;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-muted);padding-bottom:10px;border-bottom:1px solid var(--color-border);margin-bottom:24px;}
|
||||||
|
.prose{font-size:13px;color:var(--color-text-muted);line-height:1.65;max-width:720px;margin-bottom:20px;}
|
||||||
|
|
||||||
|
.scr{margin-bottom:56px;}
|
||||||
|
.scr-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;}
|
||||||
|
.scr-head h3{font-family:var(--font-display);font-size:20px;font-weight:500;letter-spacing:-.02em;}
|
||||||
|
.scr-id{font-family:var(--font-mono);font-size:11px;color:var(--color-text-muted);padding:2px 8px;border:1px solid var(--color-border);border-radius:var(--radius-sm);background:var(--color-page);}
|
||||||
|
.scr-desc{font-size:12px;color:var(--color-text-muted);line-height:1.6;max-width:720px;margin-bottom:6px;}
|
||||||
|
.scr-var{font-size:11px;color:var(--color-text-muted);margin-bottom:20px;}.scr-var strong{color:var(--color-text);}
|
||||||
|
|
||||||
|
.previews{display:flex;gap:32px;flex-wrap:wrap;justify-content:center;align-items:flex-start;margin-bottom:20px;}
|
||||||
|
.prev-col{display:flex;flex-direction:column;align-items:center;gap:10px;}
|
||||||
|
.bp-lbl{font-family:var(--font-mono);font-size:10px;color:var(--color-text-muted);}
|
||||||
|
|
||||||
|
.desk{width:100%;max-width:1040px;background:var(--color-page);border-radius:var(--radius-xl);overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.06);display:flex;flex-direction:column;min-height:520px;}
|
||||||
|
|
||||||
|
.phone{width:320px;flex-shrink:0;background:var(--color-page);border-radius:36px;overflow:hidden;box-shadow:var(--shadow-overlay),0 0 0 1px rgba(0,0,0,.07);display:flex;flex-direction:column;border:6px solid #1C1C18;}
|
||||||
|
.pst{padding:10px 20px 0;display:flex;justify-content:space-between;align-items:center;font-size:12px;background:var(--color-page);}.pst b{font-weight:600;}.pst span{font-size:10px;}
|
||||||
|
.pb{flex:1;overflow-y:auto;display:flex;flex-direction:column;}
|
||||||
|
|
||||||
|
/* ── FA chrome ── */
|
||||||
|
.fa-nav{height:32px;background:var(--navy);display:flex;align-items:center;padding:0 12px;gap:8px;flex-shrink:0;}
|
||||||
|
.fa-logo{font-size:7px;font-weight:900;color:#fff;letter-spacing:.8px;border-bottom:2px solid var(--mint);padding-bottom:1px;}
|
||||||
|
.fa-link{font-size:5.5px;color:rgba(255,255,255,.4);font-weight:700;text-transform:uppercase;}
|
||||||
|
.fa-nav-r{margin-left:auto;display:flex;gap:5px;align-items:center;}
|
||||||
|
.fa-av{width:16px;height:16px;background:rgba(255,255,255,.1);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;color:rgba(255,255,255,.5);}
|
||||||
|
|
||||||
|
/* ── Topbar ── */
|
||||||
|
.fa-topbar{background:#fff;border-bottom:1px solid #e4e2d7;display:flex;align-items:center;padding:0 12px;gap:6px;height:42px;flex-shrink:0;}
|
||||||
|
.fa-topbar .back{width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--color-text-muted);}
|
||||||
|
.fa-topbar .title{font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||||
|
.fa-chip{display:inline-flex;align-items:center;gap:2px;padding:1px 5px 1px 2px;background:var(--sand);border:1px solid #e4e2d7;border-radius:8px;white-space:nowrap;font-size:7px;color:var(--color-text);}
|
||||||
|
.fa-chip .av{width:12px;height:12px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:5px;font-weight:800;flex-shrink:0;}
|
||||||
|
.fa-chip .av.navy{background:var(--navy);color:var(--mint);}
|
||||||
|
.fa-chip .av.purple{background:#5A3080;color:#fff;}
|
||||||
|
.fa-topbar-btn{font-size:7px;font-weight:600;padding:3px 8px;border-radius:4px;border:1px solid var(--navy);color:var(--navy);background:transparent;display:flex;align-items:center;gap:3px;cursor:pointer;}
|
||||||
|
.fa-topbar-btn.ghost{border-color:var(--color-border);color:var(--color-text-muted);font-weight:500;}
|
||||||
|
.details-toggle{display:inline-flex;align-items:center;gap:3px;padding:2px 8px 2px 6px;border-radius:4px;font-size:7px;font-weight:600;color:var(--color-text-muted);cursor:pointer;border:1px solid var(--color-border);background:transparent;white-space:nowrap;}
|
||||||
|
|
||||||
|
/* ── Mode switcher ── */
|
||||||
|
.mode-sw{display:inline-flex;border:1px solid var(--color-border);border-radius:4px;overflow:hidden;font-size:6px;font-weight:600;}
|
||||||
|
.mode-sw span{padding:3px 8px;cursor:pointer;color:var(--color-text-muted);}
|
||||||
|
.mode-sw span.active{background:var(--navy);color:#fff;border-color:var(--navy);}
|
||||||
|
.mode-sw span:not(.active):hover{background:var(--sand);}
|
||||||
|
|
||||||
|
/* ── PDF area ── */
|
||||||
|
.pdf-area{background:#D4D0C8;flex:1;display:flex;align-items:center;justify-content:center;position:relative;overflow:hidden;}
|
||||||
|
.paper{background:#FFFEF8;box-shadow:0 2px 8px rgba(0,0,0,.14);border-radius:1px;padding:9px 11px;display:flex;flex-direction:column;gap:2px;position:relative;}
|
||||||
|
.pl{height:3px;background:#C4BDB0;border-radius:1px;opacity:.5;margin-bottom:2px;}
|
||||||
|
.ps{height:2px;background:#C4BDB0;border-radius:1px;opacity:.28;margin-bottom:1.5px;}
|
||||||
|
|
||||||
|
/* ── Annotation rects (dimmed in read mode) ── */
|
||||||
|
.ann-rect{position:absolute;border-radius:2px;}
|
||||||
|
.ann-rect.trans{border:1.5px solid var(--turquoise);background:rgba(0,199,177,.1);}
|
||||||
|
.ann-rect.trans.dimmed{border-color:rgba(0,199,177,.3);background:rgba(0,199,177,.04);}
|
||||||
|
|
||||||
|
/* ── Split ── */
|
||||||
|
.split{display:flex;flex:1;overflow:hidden;}
|
||||||
|
.split-handle{width:4px;background:var(--color-border);cursor:col-resize;flex-shrink:0;display:flex;align-items:center;justify-content:center;}
|
||||||
|
.split-handle::after{content:'';width:2px;height:20px;background:var(--color-text-muted);border-radius:1px;opacity:.3;}
|
||||||
|
|
||||||
|
/* ── Read-mode text ── */
|
||||||
|
.read-text{font-family:var(--font-read);font-size:10px;line-height:1.85;color:var(--color-text);padding:16px 20px;}
|
||||||
|
.read-text .para{margin-bottom:10px;cursor:pointer;padding:2px 4px;border-radius:3px;transition:background .15s ease;}
|
||||||
|
.read-text .para:hover{background:rgba(0,199,177,.06);}
|
||||||
|
.read-text .para.highlighted{background:rgba(0,199,177,.1);transition:background .3s ease;}
|
||||||
|
.read-text .greeting{font-style:italic;}
|
||||||
|
.read-text .closing{margin-top:12px;text-align:right;font-style:italic;}
|
||||||
|
.read-text .illegible{color:var(--color-text-muted);font-style:italic;font-size:9px;}
|
||||||
|
|
||||||
|
/* ── Status bar ── */
|
||||||
|
.status-bar{background:var(--sand);border-top:1px solid #e4e2d7;height:18px;display:flex;align-items:center;padding:0 8px;font-size:7px;color:var(--color-text-muted);gap:8px;flex-shrink:0;}
|
||||||
|
|
||||||
|
/* ── Scroll sync highlight on PDF ── */
|
||||||
|
.ann-rect.highlight-flash{border-color:var(--turquoise) !important;background:rgba(0,199,177,.18) !important;transition:all .3s ease;animation:flash-fade 1.5s ease-out forwards;}
|
||||||
|
@keyframes flash-fade{0%{background:rgba(0,199,177,.18);border-color:var(--turquoise);}100%{background:rgba(0,199,177,.04);border-color:rgba(0,199,177,.3);}}
|
||||||
|
|
||||||
|
/* ── Agent table ── */
|
||||||
|
.agent{background:var(--color-text);color:#E8E8E2;padding:24px;border-radius:var(--radius-lg);margin-top:20px;}
|
||||||
|
.agent h4{font-size:9px;font-weight:500;letter-spacing:.1em;text-transform:uppercase;color:#5A5A55;margin-bottom:12px;}
|
||||||
|
.agent pre{font-family:var(--font-mono);font-size:10px;color:#444440;margin-bottom:16px;line-height:1.8;white-space:pre-wrap;}
|
||||||
|
.at{width:100%;border-collapse:collapse;font-family:var(--font-mono);font-size:10px;}
|
||||||
|
.at thead tr{border-bottom:1px solid #2A2A26;}.at th{text-align:left;padding:6px 10px;font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#5A5A55;font-family:var(--font-sans);}.at td{padding:5px 10px;border-bottom:1px solid #1E1E1A;vertical-align:top;line-height:1.5;}.at tr:last-child td{border-bottom:none;}.at td:first-child{color:#7A7A72;}.at td:nth-child(2){color:#E8E8E2;font-weight:500;}.at td:nth-child(3){color:#5A5A55;}.at .grp td{padding-top:14px;font-family:var(--font-sans);font-size:8px;font-weight:500;letter-spacing:.08em;text-transform:uppercase;color:#3A3A36;}
|
||||||
|
|
||||||
|
.llm{background:var(--color-page);border:2px solid var(--navy);border-radius:var(--radius-xl);padding:32px 40px;margin-top:64px;}
|
||||||
|
.llm h2{font-family:var(--font-display);font-size:22px;font-weight:500;letter-spacing:-.02em;margin-bottom:8px;color:var(--navy);}
|
||||||
|
.llm h3{font-size:14px;font-weight:600;margin:20px 0 8px;color:var(--color-text);}
|
||||||
|
.llm h4{font-size:12px;font-weight:600;margin:16px 0 6px;color:var(--color-text);}
|
||||||
|
.llm p,.llm li{font-size:13px;color:var(--color-text-muted);line-height:1.65;}
|
||||||
|
.llm ul,.llm ol{padding-left:20px;margin-bottom:12px;}
|
||||||
|
.llm li{margin-bottom:4px;}
|
||||||
|
.llm code{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:1px 5px;border-radius:3px;}
|
||||||
|
.llm pre{font-family:var(--font-mono);font-size:11px;background:var(--color-surface);padding:12px 16px;border-radius:var(--radius-md);overflow-x:auto;margin:8px 0 12px;line-height:1.6;}
|
||||||
|
.llm table{width:100%;border-collapse:collapse;margin:12px 0;font-size:12px;}
|
||||||
|
.llm th,.llm td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--color-border);}
|
||||||
|
.llm th{font-weight:500;color:var(--color-text);font-size:11px;text-transform:uppercase;letter-spacing:.05em;}
|
||||||
|
.llm td{color:var(--color-text-muted);}
|
||||||
|
|
||||||
|
@media(max-width:900px){.doc{padding:24px 16px 80px;}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="doc">
|
||||||
|
|
||||||
|
<div class="doc-header">
|
||||||
|
<div>
|
||||||
|
<h1>Transcription Read Mode — Final Spec</h1>
|
||||||
|
<p>A focused reading experience for completed transcriptions. Uses the <strong>clean split</strong> layout: PDF scan on the left, flowing prose on the right. All editing chrome is stripped — no block borders, no comment threads, no toolbars. The text reads like a letter, not like an editing interface.</p>
|
||||||
|
</div>
|
||||||
|
<div class="doc-meta">
|
||||||
|
Familienarchiv<br/>
|
||||||
|
<span class="pill pill-g">Final</span><br/>
|
||||||
|
2026-04-05 · @leonievoss
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
SECTION: DESIGN RATIONALE
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Design rationale</div>
|
||||||
|
<p class="prose">Transcribe mode is for editing. But most visits to a completed transcription are for <strong>reading</strong> — comparing the handwriting with the typed text, sharing with family, or just revisiting a letter. Read mode strips away all editing chrome and presents the transcription as flowing prose alongside the original scan.</p>
|
||||||
|
<p class="prose">The <strong>clean split</strong> was chosen over the full-page reader (PDF hidden) and the interleaved view (cropped PDF per block) because it preserves the familiar side-by-side layout from transcribe mode while dramatically reducing visual noise. Users can switch between reading and editing without re-learning the spatial layout.</p>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:12px;flex-wrap:wrap;font-size:12px;margin-top:20px;">
|
||||||
|
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:180px;">
|
||||||
|
<div style="font-weight:600;color:var(--green);margin-bottom:4px;">Kept</div>
|
||||||
|
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||||
|
<li>Transcription text (flowing prose)</li>
|
||||||
|
<li>PDF scan viewer (same position)</li>
|
||||||
|
<li>Topbar (title, Details toggle, person chips)</li>
|
||||||
|
<li>Mode switcher (Lesen / Bearbeiten)</li>
|
||||||
|
<li>Resizable split handle</li>
|
||||||
|
<li>Scroll sync (click paragraph ↔ PDF)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div style="background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);padding:10px 14px;flex:1;min-width:180px;">
|
||||||
|
<div style="font-weight:600;color:var(--color-error);margin-bottom:4px;">Removed</div>
|
||||||
|
<ul style="padding-left:16px;color:var(--color-text-muted);line-height:1.8;">
|
||||||
|
<li>Block borders & numbered badges</li>
|
||||||
|
<li>Contenteditable / cursor</li>
|
||||||
|
<li>Comment threads & “Kommentieren” buttons</li>
|
||||||
|
<li>Presence dots & user indicators</li>
|
||||||
|
<li>Hint strip (“Markiere eine Passage…”)</li>
|
||||||
|
<li>Drag handles, sort controls</li>
|
||||||
|
<li>Auto-save status indicator</li>
|
||||||
|
<li>Add-block CTA</li>
|
||||||
|
<li>History / “Verlauf” button</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
S1: DESKTOP — READ MODE
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="s1">
|
||||||
|
<div class="scr-head"><h3>S1 — Desktop read mode</h3><span class="scr-id">S1</span></div>
|
||||||
|
<div class="scr-desc">Side-by-side split: PDF scan on the left with dimmed annotation outlines, flowing serif prose on the right. The mode switcher shows “Lesen” as active. Clicking a paragraph briefly highlights the matching PDF region (turquoise flash, 1.5s fade).</div>
|
||||||
|
<div class="scr-var"><strong>Primary reading state</strong> — the default view when a transcription exists.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="fa-link">Dokumente</div>
|
||||||
|
<div class="fa-link">Personen</div>
|
||||||
|
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="fa-topbar">
|
||||||
|
<div class="back">←</div>
|
||||||
|
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="fa-chip"><div class="av navy">HR</div> Heinrich R.</div>
|
||||||
|
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||||
|
<div class="fa-chip"><div class="av purple">MR</div> Martha R.</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="details-toggle">Details ▼</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<!-- Mode switcher: Lesen active -->
|
||||||
|
<div class="mode-sw"><span class="active">Lesen</span><span>Bearbeiten</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split" style="height:430px;">
|
||||||
|
<!-- PDF scan — annotations dimmed -->
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;">
|
||||||
|
<div class="pdf-area" style="flex:1;">
|
||||||
|
<div class="paper" style="width:55%;min-height:260px;position:relative;">
|
||||||
|
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||||
|
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||||
|
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||||
|
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||||
|
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||||
|
|
||||||
|
<!-- Dimmed annotation outlines — no numbered badges -->
|
||||||
|
<div class="ann-rect trans dimmed" style="left:2%;top:0%;width:50%;height:10%;"></div>
|
||||||
|
<div class="ann-rect trans dimmed" style="left:2%;top:14%;width:96%;height:32%;"></div>
|
||||||
|
<div class="ann-rect trans dimmed" style="left:2%;top:50%;width:96%;height:22%;"></div>
|
||||||
|
<div class="ann-rect trans dimmed" style="left:20%;top:80%;width:60%;height:12%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split-handle"></div>
|
||||||
|
|
||||||
|
<!-- Right panel: flowing prose, no block chrome -->
|
||||||
|
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;">
|
||||||
|
<div class="read-text" style="flex:1;overflow-y:auto;">
|
||||||
|
<div class="para greeting">Liebe Martha,</div>
|
||||||
|
<div class="para">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||||
|
<div class="para">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.</div>
|
||||||
|
<div class="para closing">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar">
|
||||||
|
<span>4 Abschnitte</span>
|
||||||
|
<span style="margin-left:auto;">Zuletzt bearbeitet: Oma Inge, 14:23</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>S1 · Desktop read mode</h4>
|
||||||
|
<pre>/* Same side-by-side layout as transcribe mode, but the right panel renders
|
||||||
|
* the transcription as continuous flowing prose instead of block cards.
|
||||||
|
*
|
||||||
|
* Key differences from transcribe mode:
|
||||||
|
* - No block borders, headers, footers, or numbered badges
|
||||||
|
* - No contenteditable — text is plain rendered HTML
|
||||||
|
* - No comment threads, no "Kommentieren" buttons
|
||||||
|
* - No presence dots, no hint strip, no auto-save indicator
|
||||||
|
* - Annotation rects on PDF are dimmed (opacity ~0.3, no badges)
|
||||||
|
* - Still clickable for scroll-sync
|
||||||
|
* - Status bar shows: "4 Abschnitte · Zuletzt bearbeitet: Oma Inge, 14:23"
|
||||||
|
*
|
||||||
|
* Scroll sync:
|
||||||
|
* - Click paragraph → matching PDF annotation flashes turquoise (1.5s fade)
|
||||||
|
* - Click PDF annotation → matching paragraph gets subtle bg highlight (1.5s fade)
|
||||||
|
* - PDF auto-scrolls to center the annotation in the viewport */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Text panel</td></tr>
|
||||||
|
<tr><td>Font</td><td>Tinos (serif), 16px, line-height 1.85</td><td>Generous reading typography</td></tr>
|
||||||
|
<tr><td>Padding</td><td>24px 32px</td><td>Comfortable margins like a book page</td></tr>
|
||||||
|
<tr><td>Paragraphs</td><td>One <p> per transcription block</td><td>mb-4 between paragraphs</td></tr>
|
||||||
|
<tr><td>[unleserlich]</td><td>italic, text-ink-2, font-size: 0.9em</td><td>Subtle but readable</td></tr>
|
||||||
|
<tr><td>Hover</td><td>Subtle turquoise bg at 6% opacity</td><td>Hint that paragraphs are clickable</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">PDF panel</td></tr>
|
||||||
|
<tr><td>Annotations</td><td>Dimmed: border-opacity 0.3, bg-opacity 0.04</td><td>Still clickable for scroll-sync</td></tr>
|
||||||
|
<tr><td>Badges</td><td>Hidden</td><td>No numbered circles in read mode</td></tr>
|
||||||
|
<tr><td>Scroll sync</td><td>Click para → PDF scrolls, flash 1.5s</td><td>Turquoise tint at 18% → fade to 4%</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Status bar</td></tr>
|
||||||
|
<tr><td>Content</td><td>"N Abschnitte · Zuletzt bearbeitet: Name, HH:mm"</td><td>Uses most recent updated_at across blocks</td></tr>
|
||||||
|
<tr><td>Height</td><td>28px, sand background</td><td>Same as transcribe mode status bar</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
S2: DESKTOP — SCROLL SYNC INTERACTION
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="s2">
|
||||||
|
<div class="scr-head"><h3>S2 — Scroll sync highlight</h3><span class="scr-id">S2</span></div>
|
||||||
|
<div class="scr-desc">The user clicked the second paragraph. The matching PDF annotation flashes with a turquoise highlight that fades over 1.5 seconds. The paragraph itself gets a subtle background tint. This is the <strong>only interactive element</strong> in read mode — no editing, no comments.</div>
|
||||||
|
<div class="scr-var"><strong>Click-to-highlight interaction</strong> — bidirectional scroll sync between text and scan.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="fa-link">Dokumente</div>
|
||||||
|
<div class="fa-link">Personen</div>
|
||||||
|
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="fa-topbar">
|
||||||
|
<div class="back">←</div>
|
||||||
|
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="fa-chip"><div class="av navy">HR</div> Heinrich R.</div>
|
||||||
|
<span style="font-size:7px;color:var(--color-text-muted);">→</span>
|
||||||
|
<div class="fa-chip"><div class="av purple">MR</div> Martha R.</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="details-toggle">Details ▼</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<div class="mode-sw"><span class="active">Lesen</span><span>Bearbeiten</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split" style="height:430px;">
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;">
|
||||||
|
<div class="pdf-area" style="flex:1;">
|
||||||
|
<div class="paper" style="width:55%;min-height:260px;position:relative;">
|
||||||
|
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||||
|
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div><div class="ps" style="width:70%;"></div>
|
||||||
|
<div class="pl" style="width:84%;"></div><div class="ps" style="width:90%;"></div><div class="ps" style="width:60%;"></div>
|
||||||
|
<div class="pl" style="width:75%;"></div><div class="ps" style="width:82%;"></div>
|
||||||
|
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||||
|
|
||||||
|
<div class="ann-rect trans dimmed" style="left:2%;top:0%;width:50%;height:10%;"></div>
|
||||||
|
<!-- THIS annotation is highlighted — user clicked paragraph 2 -->
|
||||||
|
<div class="ann-rect trans highlight-flash" style="left:2%;top:14%;width:96%;height:32%;"></div>
|
||||||
|
<div class="ann-rect trans dimmed" style="left:2%;top:50%;width:96%;height:22%;"></div>
|
||||||
|
<div class="ann-rect trans dimmed" style="left:20%;top:80%;width:60%;height:12%;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split-handle"></div>
|
||||||
|
|
||||||
|
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;">
|
||||||
|
<div class="read-text" style="flex:1;overflow-y:auto;">
|
||||||
|
<div class="para greeting">Liebe Martha,</div>
|
||||||
|
<!-- THIS paragraph is highlighted -->
|
||||||
|
<div class="para highlighted">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <span class="illegible">[unleserlich]</span> Wochen noch dauern wird.</div>
|
||||||
|
<div class="para">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.</div>
|
||||||
|
<div class="para closing">In ewiger Liebe,<br/>Dein Heinrich</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-bar">
|
||||||
|
<span>4 Abschnitte</span>
|
||||||
|
<span style="margin-left:auto;">Zuletzt bearbeitet: Oma Inge, 14:23</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>S2 · Scroll sync highlight</h4>
|
||||||
|
<pre>/* Bidirectional scroll sync with visual feedback.
|
||||||
|
*
|
||||||
|
* Text → PDF:
|
||||||
|
* 1. User clicks a paragraph
|
||||||
|
* 2. Paragraph gets .highlighted class (turquoise bg at 10%)
|
||||||
|
* 3. Matching annotation rect gets .highlight-flash class
|
||||||
|
* 4. PDF viewport scrolls to center the annotation
|
||||||
|
* 5. Both highlights fade over 1.5s via CSS animation
|
||||||
|
*
|
||||||
|
* PDF → Text:
|
||||||
|
* 1. User clicks a dimmed annotation rect
|
||||||
|
* 2. Annotation flashes (same .highlight-flash)
|
||||||
|
* 3. Matching paragraph gets .highlighted
|
||||||
|
* 4. Text panel scrolls to center the paragraph
|
||||||
|
* 5. Both fade over 1.5s
|
||||||
|
*
|
||||||
|
* Implementation: each paragraph has data-block-id matching the
|
||||||
|
* transcription block's annotation_id. The annotation rects already
|
||||||
|
* have annotation IDs from transcribe mode. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Highlight animation</td></tr>
|
||||||
|
<tr><td>Paragraph bg</td><td>rgba(0,199,177,.10)</td><td>Turquoise at 10%, fades to 0</td></tr>
|
||||||
|
<tr><td>Annotation flash</td><td>rgba(0,199,177,.18) → .04</td><td>Border returns to .3 opacity</td></tr>
|
||||||
|
<tr><td>Duration</td><td>1.5s ease-out</td><td>CSS animation, no JS timers needed</td></tr>
|
||||||
|
<tr><td>Scroll behavior</td><td>smooth, block: center</td><td>scrollIntoView({ behavior: 'smooth', block: 'center' })</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Data binding</td></tr>
|
||||||
|
<tr><td>Paragraph attr</td><td>data-block-id="{annotation_id}"</td><td>Links text to PDF annotation</td></tr>
|
||||||
|
<tr><td>Annotation attr</td><td>data-annotation-id="{id}"</td><td>Already exists from transcribe mode</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
S3: DESKTOP — NO TRANSCRIPTION YET
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="s3">
|
||||||
|
<div class="scr-head"><h3>S3 — No transcription (empty state)</h3><span class="scr-id">S3</span></div>
|
||||||
|
<div class="scr-desc">When no transcription blocks exist, the mode switcher defaults to “Bearbeiten” and the right panel shows an empty state encouraging the user to start transcribing. The “Lesen” tab is disabled (greyed out).</div>
|
||||||
|
<div class="scr-var"><strong>Empty state</strong> — no read mode available until at least one block exists.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Desktop · 1040px</div>
|
||||||
|
<div class="desk" style="min-height:400px;">
|
||||||
|
<div class="fa-nav">
|
||||||
|
<div class="fa-logo">FAMILIENARCHIV</div>
|
||||||
|
<div class="fa-link">Dokumente</div>
|
||||||
|
<div class="fa-link">Personen</div>
|
||||||
|
<div class="fa-nav-r"><div class="fa-av">MR</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="fa-topbar">
|
||||||
|
<div class="back">←</div>
|
||||||
|
<div class="title">Brief von Heinrich an Martha, 14. Mai 1943</div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<div class="details-toggle">Details ▼</div>
|
||||||
|
<div style="width:1px;height:16px;background:#e4e2d7;margin:0 4px;"></div>
|
||||||
|
<!-- Mode switcher: Bearbeiten active, Lesen disabled -->
|
||||||
|
<div class="mode-sw"><span style="opacity:.35;cursor:not-allowed;">Lesen</span><span class="active">Bearbeiten</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split" style="height:320px;">
|
||||||
|
<div style="flex:1;display:flex;flex-direction:column;">
|
||||||
|
<div class="pdf-area" style="flex:1;">
|
||||||
|
<div class="paper" style="width:55%;min-height:200px;">
|
||||||
|
<div style="font-size:7px;color:#8A8070;font-style:italic;margin-bottom:4px;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;"></div><div class="ps" style="width:85%;"></div><div class="ps" style="width:92%;"></div>
|
||||||
|
<div class="pl" style="width:78%;"></div><div class="ps" style="width:88%;"></div>
|
||||||
|
<div style="font-size:6px;color:#8A8070;margin-top:6px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="split-handle"></div>
|
||||||
|
|
||||||
|
<div style="width:400px;display:flex;flex-direction:column;border-left:1px solid #e4e2d7;background:#fff;align-items:center;justify-content:center;text-align:center;padding:32px;">
|
||||||
|
<div style="width:48px;height:48px;border-radius:50%;background:var(--sand);display:flex;align-items:center;justify-content:center;margin-bottom:12px;">
|
||||||
|
<span style="font-size:20px;opacity:.5;">✎</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-family:var(--font-sans);font-size:11px;font-weight:600;color:var(--color-text);margin-bottom:4px;">Noch keine Transkription</div>
|
||||||
|
<div style="font-family:var(--font-sans);font-size:10px;color:var(--color-text-muted);max-width:200px;line-height:1.6;">Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>S3 · Empty state</h4>
|
||||||
|
<pre>/* When transcription_blocks count is 0:
|
||||||
|
* - Mode switcher defaults to "Bearbeiten"
|
||||||
|
* - "Lesen" tab is disabled: opacity 0.35, cursor: not-allowed, not clickable
|
||||||
|
* - Right panel shows empty state with pencil icon, title, and description
|
||||||
|
* - No status bar (nothing to show)
|
||||||
|
*
|
||||||
|
* As soon as the first block is saved, "Lesen" becomes enabled.
|
||||||
|
* The mode does NOT auto-switch — user stays in Bearbeiten. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Empty state</td></tr>
|
||||||
|
<tr><td>Icon</td><td>Pencil in 48px sand circle</td><td>Centered vertically in panel</td></tr>
|
||||||
|
<tr><td>Title</td><td>"Noch keine Transkription"</td><td>i18n key: transcription_empty_title</td></tr>
|
||||||
|
<tr><td>Description</td><td>"Zeichne Bereiche auf dem Scan…"</td><td>i18n key: transcription_empty_desc</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Mode switcher</td></tr>
|
||||||
|
<tr><td>Lesen tab</td><td>Disabled: opacity .35, not-allowed</td><td>Enabled when block count > 0</td></tr>
|
||||||
|
<tr><td>Default</td><td>"Bearbeiten" active</td><td>Enters transcribe mode directly</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
S4: MOBILE — READ MODE
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="s4">
|
||||||
|
<div class="scr-head"><h3>S4 — Mobile read mode</h3><span class="scr-id">S4</span></div>
|
||||||
|
<div class="scr-desc">On mobile, the split view becomes vertical: a collapsible PDF strip (70px) at the top, flowing text below. The mode switcher abbreviates “Bearbeiten” to “Bearb.” to fit. Tapping the PDF strip expands it; tapping again collapses. Paragraphs are still tappable for scroll-sync.</div>
|
||||||
|
<div class="scr-var"><strong>Mobile layout</strong> — stacked vertical with collapsible scan strip.</div>
|
||||||
|
|
||||||
|
<div class="previews">
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px</div>
|
||||||
|
<div class="phone" style="height:620px;">
|
||||||
|
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||||
|
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||||
|
<div class="mode-sw"><span class="active" style="font-size:7px;">Lesen</span><span style="font-size:7px;">Bearb.</span></div>
|
||||||
|
</div>
|
||||||
|
<!-- Collapsible PDF strip -->
|
||||||
|
<div style="background:#D4D0C8;height:70px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #C4C0B8;position:relative;">
|
||||||
|
<div style="background:#FFFEF8;width:42%;padding:5px 7px;box-shadow:0 1px 3px rgba(0,0,0,.1);border-radius:1px;">
|
||||||
|
<div style="font-size:5px;color:#8A8070;font-style:italic;opacity:.7;">Liebe Martha,</div>
|
||||||
|
<div style="height:2px;background:#C4BDB0;opacity:.4;margin:2px 0;width:80%;"></div>
|
||||||
|
<div style="height:1.5px;background:#C4BDB0;opacity:.2;margin:1px 0;width:90%;"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Expand hint -->
|
||||||
|
<div style="position:absolute;bottom:3px;right:8px;font-size:6px;color:var(--color-text-muted);opacity:.6;">▲ Scan vergrößern</div>
|
||||||
|
</div>
|
||||||
|
<!-- Flowing text -->
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:16px 16px;background:#fff;">
|
||||||
|
<div style="font-family:'Tinos',Georgia,serif;font-size:13px;line-height:1.9;color:var(--color-text);">
|
||||||
|
<p style="margin-bottom:10px;font-style:italic;">Liebe Martha,</p>
|
||||||
|
<p style="margin-bottom:10px;">ich schreibe Dir heute aus dem Lazarett in Breslau. Mach Dir keine Sorgen, es geht mir den Umständen entsprechend gut. Der Arzt sagt <em style="color:var(--color-text-muted);">[unleserlich]</em> Wochen noch dauern wird.</p>
|
||||||
|
<p style="margin-bottom:10px;">Die Kinder sollen wissen, dass ich an sie denke. Sag dem kleinen Fritz, er soll auf seine Mutter aufpassen. Und Lotte soll weiter so fleißig in der Schule sein.</p>
|
||||||
|
<p style="text-align:right;font-style:italic;">In ewiger Liebe,<br/>Dein Heinrich</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Status bar -->
|
||||||
|
<div style="background:var(--sand);border-top:1px solid #e4e2d7;padding:4px 12px;font-size:8px;color:var(--color-text-muted);display:flex;justify-content:space-between;">
|
||||||
|
<span>4 Abschnitte</span>
|
||||||
|
<span>Oma Inge, 14:23</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile: expanded PDF strip -->
|
||||||
|
<div class="prev-col">
|
||||||
|
<div class="bp-lbl">Mobile · 320px · scan expanded</div>
|
||||||
|
<div class="phone" style="height:620px;">
|
||||||
|
<div class="pst"><b>14:23</b><span>••• WiFi 🔋</span></div>
|
||||||
|
<div class="pb">
|
||||||
|
<div style="background:#fff;border-bottom:1px solid #e4e2d7;padding:6px 12px;display:flex;align-items:center;gap:6px;">
|
||||||
|
<span style="font-size:11px;color:var(--color-text-muted);">←</span>
|
||||||
|
<span style="font-family:Georgia,serif;font-size:11px;color:var(--navy);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">Brief von Heinrich, 14.05.1943</span>
|
||||||
|
<div class="mode-sw"><span class="active" style="font-size:7px;">Lesen</span><span style="font-size:7px;">Bearb.</span></div>
|
||||||
|
</div>
|
||||||
|
<!-- Expanded PDF strip -->
|
||||||
|
<div style="background:#D4D0C8;height:200px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid #C4C0B8;position:relative;">
|
||||||
|
<div style="background:#FFFEF8;width:50%;padding:8px 10px;box-shadow:0 2px 6px rgba(0,0,0,.12);border-radius:1px;position:relative;">
|
||||||
|
<div style="font-size:6px;color:#8A8070;font-style:italic;opacity:.7;margin-bottom:3px;">Liebe Martha,</div>
|
||||||
|
<div class="pl" style="width:90%;height:2px;"></div><div class="ps" style="width:85%;height:1.5px;"></div><div class="ps" style="width:92%;height:1.5px;"></div>
|
||||||
|
<div class="pl" style="width:78%;height:2px;"></div><div class="ps" style="width:88%;height:1.5px;"></div>
|
||||||
|
<div class="pl" style="width:84%;height:2px;"></div><div class="ps" style="width:70%;height:1.5px;"></div>
|
||||||
|
<div style="font-size:5px;color:#8A8070;margin-top:4px;text-align:right;opacity:.7;">Dein Heinrich</div>
|
||||||
|
<!-- Dimmed annotations visible when expanded -->
|
||||||
|
<div class="ann-rect trans dimmed" style="left:3%;top:0%;width:45%;height:12%;"></div>
|
||||||
|
<div class="ann-rect trans dimmed" style="left:3%;top:16%;width:94%;height:35%;"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Collapse hint -->
|
||||||
|
<div style="position:absolute;bottom:3px;right:8px;font-size:6px;color:var(--color-text-muted);opacity:.6;">▼ Scan verkleinern</div>
|
||||||
|
</div>
|
||||||
|
<!-- Text below (shorter) -->
|
||||||
|
<div style="flex:1;overflow-y:auto;padding:14px 16px;background:#fff;">
|
||||||
|
<div style="font-family:'Tinos',Georgia,serif;font-size:13px;line-height:1.9;color:var(--color-text);">
|
||||||
|
<p style="margin-bottom:10px;font-style:italic;">Liebe Martha,</p>
|
||||||
|
<p style="margin-bottom:10px;">ich schreibe Dir heute aus dem Lazarett in Breslau…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>S4 · Mobile read mode</h4>
|
||||||
|
<pre>/* On viewports < 768px, the side-by-side split becomes vertical:
|
||||||
|
* - PDF scan strip at top (collapsed: 70px, expanded: ~50vh)
|
||||||
|
* - Flowing text below, full-width
|
||||||
|
* - Tap PDF strip to toggle expand/collapse
|
||||||
|
* - Expand hint text: "▲ Scan vergrößern" / "▼ Scan verkleinern"
|
||||||
|
*
|
||||||
|
* Mode switcher abbreviates: "Lesen | Bearb."
|
||||||
|
* Scroll-sync: tapping a paragraph briefly highlights the matching
|
||||||
|
* region in the expanded PDF. If PDF is collapsed, it auto-expands
|
||||||
|
* first, then scrolls to the annotation.
|
||||||
|
*
|
||||||
|
* Same status bar at the bottom, same flowing prose styling. */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">PDF strip</td></tr>
|
||||||
|
<tr><td>Collapsed height</td><td>70px</td><td>Shows miniature scan preview</td></tr>
|
||||||
|
<tr><td>Expanded height</td><td>~50vh or max 300px</td><td>Smooth CSS transition (300ms ease)</td></tr>
|
||||||
|
<tr><td>Toggle</td><td>Tap anywhere on strip</td><td>Hint text in bottom-right corner</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Text area</td></tr>
|
||||||
|
<tr><td>Font</td><td>Tinos, 15px, line-height 1.9</td><td>Slightly larger than desktop for touch</td></tr>
|
||||||
|
<tr><td>Padding</td><td>16px</td><td>Full-width, no wasted space</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Mode switcher</td></tr>
|
||||||
|
<tr><td>Labels</td><td>"Lesen | Bearb."</td><td>Abbreviated to fit mobile topbar</td></tr>
|
||||||
|
<tr><td>Font size</td><td>10px</td><td>Compact but readable</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Scroll sync on mobile</td></tr>
|
||||||
|
<tr><td>Tap paragraph</td><td>Expand PDF if collapsed, then highlight</td><td>Auto-expand + scroll + flash</td></tr>
|
||||||
|
<tr><td>Tap annotation</td><td>Collapse PDF, scroll text to paragraph</td><td>Smart collapse after showing match</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
S5: MODE SWITCHER DETAIL
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="scr" id="s5">
|
||||||
|
<div class="scr-head"><h3>S5 — Mode switcher states</h3><span class="scr-id">S5</span></div>
|
||||||
|
<div class="scr-desc">The mode switcher is a segmented control in the topbar that replaces the previous “Transkribieren” turquoise button. It governs three visual states: <strong>Lesen</strong> (read mode, this spec), <strong>Bearbeiten</strong> (transcribe/edit mode), and the existing <strong>Annotieren</strong> button (yellow comment annotations). The modes are mutually exclusive.</div>
|
||||||
|
<div class="scr-var"><strong>Segmented control</strong> — replacing the turquoise “Transkribieren” button.</div>
|
||||||
|
|
||||||
|
<div class="previews" style="gap:16px;">
|
||||||
|
<!-- State 1: Lesen active -->
|
||||||
|
<div class="prev-col" style="align-items:center;">
|
||||||
|
<div class="bp-lbl">Lesen active</div>
|
||||||
|
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||||
|
<div class="mode-sw" style="font-size:8px;"><span class="active" style="padding:4px 12px;">Lesen</span><span style="padding:4px 12px;">Bearbeiten</span></div>
|
||||||
|
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||||
|
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">✎ Annotieren</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- State 2: Bearbeiten active -->
|
||||||
|
<div class="prev-col" style="align-items:center;">
|
||||||
|
<div class="bp-lbl">Bearbeiten active</div>
|
||||||
|
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||||
|
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;">Lesen</span><span class="active" style="padding:4px 12px;">Bearbeiten</span></div>
|
||||||
|
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||||
|
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">✎ Annotieren</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- State 3: Annotieren active -->
|
||||||
|
<div class="prev-col" style="align-items:center;">
|
||||||
|
<div class="bp-lbl">Annotieren active (separate button)</div>
|
||||||
|
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||||
|
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;opacity:.5;">Lesen</span><span style="padding:4px 12px;opacity:.5;">Bearbeiten</span></div>
|
||||||
|
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||||
|
<div class="fa-topbar-btn" style="font-size:8px;padding:4px 10px;background:var(--navy);color:#fff;border-color:var(--navy);">✎ Annotieren</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- State 4: Lesen disabled (no blocks) -->
|
||||||
|
<div class="prev-col" style="align-items:center;">
|
||||||
|
<div class="bp-lbl">No transcription blocks</div>
|
||||||
|
<div style="background:#fff;border:1px solid #e4e2d7;border-radius:var(--radius-md);padding:12px 16px;display:flex;align-items:center;gap:8px;">
|
||||||
|
<div class="mode-sw" style="font-size:8px;"><span style="padding:4px 12px;opacity:.35;cursor:not-allowed;">Lesen</span><span class="active" style="padding:4px 12px;">Bearbeiten</span></div>
|
||||||
|
<div style="width:1px;height:20px;background:#e4e2d7;"></div>
|
||||||
|
<div class="fa-topbar-btn ghost" style="font-size:8px;padding:4px 10px;">✎ Annotieren</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="agent">
|
||||||
|
<h4>S5 · Mode switcher states</h4>
|
||||||
|
<pre>/* Three mutually exclusive modes:
|
||||||
|
*
|
||||||
|
* 1. Lesen (read) — this spec. Flowing prose, dimmed annotations, no editing.
|
||||||
|
* 2. Bearbeiten (edit) — annotation-transcription-final-spec. Block cards, contenteditable.
|
||||||
|
* 3. Annotieren — yellow comment annotations on PDF. Separate button, not in segmented control.
|
||||||
|
*
|
||||||
|
* The segmented control only contains Lesen + Bearbeiten.
|
||||||
|
* Annotieren is a separate button that, when active, deselects both Lesen and Bearbeiten
|
||||||
|
* (both appear deselected/dimmed in the segmented control).
|
||||||
|
*
|
||||||
|
* When the user clicks Annotieren while in read/transcribe mode:
|
||||||
|
* → Enter annotate mode, both segmented items dim
|
||||||
|
* When the user clicks a segmented item while in annotate mode:
|
||||||
|
* → Exit annotate mode, enter the selected mode
|
||||||
|
*
|
||||||
|
* State: let mode: 'read' | 'transcribe' | 'annotate' = $state(...)
|
||||||
|
* Default: 'read' if blocks.length > 0, else 'transcribe'
|
||||||
|
* The "Annotieren" button is hidden if !canAnnotate || !isPdf */</pre>
|
||||||
|
<table class="at"><thead><tr><th>Element</th><th>Value</th><th>Notes</th></tr></thead><tbody>
|
||||||
|
<tr class="grp"><td colspan="3">Segmented control</td></tr>
|
||||||
|
<tr><td>Items</td><td>"Lesen" | "Bearbeiten"</td><td>Mobile: "Lesen" | "Bearb."</td></tr>
|
||||||
|
<tr><td>Active style</td><td>bg:navy, color:#fff</td><td>Rounded within the pill</td></tr>
|
||||||
|
<tr><td>Inactive style</td><td>bg:transparent, color:muted</td><td>Hover: bg:sand</td></tr>
|
||||||
|
<tr><td>Dimmed style</td><td>Both items at opacity .5</td><td>Only when annotate mode is active</td></tr>
|
||||||
|
<tr><td>Disabled (Lesen)</td><td>opacity .35, cursor not-allowed</td><td>When no transcription blocks exist</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Annotieren button</td></tr>
|
||||||
|
<tr><td>Default</td><td>Ghost style (border:muted)</td><td>Same as current topbar button</td></tr>
|
||||||
|
<tr><td>Active</td><td>bg:navy, color:#fff</td><td>Filled state when annotate mode on</td></tr>
|
||||||
|
<tr><td>Visibility</td><td>canAnnotate && isPdf</td><td>Hidden for non-PDF documents</td></tr>
|
||||||
|
<tr class="grp"><td colspan="3">Accessibility</td></tr>
|
||||||
|
<tr><td>Segmented</td><td>role="tablist", children role="tab"</td><td>aria-selected on active tab</td></tr>
|
||||||
|
<tr><td>Annotieren</td><td>aria-pressed={annotateMode}</td><td>Toggle button semantics</td></tr>
|
||||||
|
<tr><td>Disabled tab</td><td>aria-disabled="true", tabindex="-1"</td><td>Not focusable when no blocks</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ═══ LLM IMPLEMENTATION GUIDE ═══ -->
|
||||||
|
<div class="llm">
|
||||||
|
<h2>Implementation Guide — Transcription Read Mode (Clean Split)</h2>
|
||||||
|
|
||||||
|
<h3>1. Overview</h3>
|
||||||
|
<p>Read mode is the default view for documents that have transcription blocks. It reuses the same side-by-side split layout as transcribe mode but replaces the editable block cards with flowing serif prose. The goal is a distraction-free reading experience that still lets users compare handwriting with typed text.</p>
|
||||||
|
|
||||||
|
<h3>2. Mode State Management</h3>
|
||||||
|
<p>The document detail page manages a single <code>mode</code> state that governs the entire view:</p>
|
||||||
|
<pre>let mode: 'read' | 'transcribe' | 'annotate' = $state(
|
||||||
|
blocks.length > 0 ? 'read' : 'transcribe'
|
||||||
|
);</pre>
|
||||||
|
<ul>
|
||||||
|
<li><code>mode === 'read'</code> → this spec (flowing prose, dimmed annotations, no editing)</li>
|
||||||
|
<li><code>mode === 'transcribe'</code> → annotation-transcription-final-spec (block cards, contenteditable)</li>
|
||||||
|
<li><code>mode === 'annotate'</code> → yellow comment annotations on PDF</li>
|
||||||
|
<li>The segmented control in the topbar toggles between <code>'read'</code> and <code>'transcribe'</code>.</li>
|
||||||
|
<li>The “Annotieren” button toggles <code>'annotate'</code> on/off. When entering annotate mode, the previous mode (read or transcribe) is stored so the user returns to it when exiting.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3. Component Architecture</h3>
|
||||||
|
<h4>3a. New components</h4>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Component</th><th>Purpose</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>TranscriptionReadView.svelte</code></td><td>Right panel content in read mode. Renders transcription blocks as flowing prose (<code><article></code> with <code><p></code> per block). Handles scroll-sync click handlers.</td></tr>
|
||||||
|
<tr><td><code>ModeSwitcher.svelte</code></td><td>Segmented control (<code>Lesen | Bearbeiten</code>). Props: <code>mode</code> (bindable), <code>hasBlocks</code> (disables Lesen when false). Emits mode changes.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h4>3b. Modified components</h4>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Component</th><th>Change</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>DocumentTopBar.svelte</code></td><td>Replace the <code>Transkribieren</code> button with <code>ModeSwitcher</code>. Keep the <code>Annotieren</code> button separate. Add <code>mode</code> bindable prop.</td></tr>
|
||||||
|
<tr><td><code>[id]/+page.svelte</code></td><td>Add <code>mode</code> state. Conditionally render <code>TranscriptionReadView</code> vs <code>TranscriptionEditView</code> in the right panel based on <code>mode</code>.</td></tr>
|
||||||
|
<tr><td><code>PdfAnnotationLayer.svelte</code></td><td>Accept <code>dimmed</code> prop. When true: annotation rects get opacity 0.3, no numbered badges, but remain clickable for scroll-sync.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>4. Read View Rendering</h3>
|
||||||
|
<h4>4a. Text rendering</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Fetch transcription blocks from <code>GET /api/documents/{id}/transcription-blocks</code> (same endpoint as transcribe mode).</li>
|
||||||
|
<li>Render each block as a <code><p data-block-id="{block.annotation_id}"></code> inside an <code><article></code> element.</li>
|
||||||
|
<li>Typography: <code>font-family: Tinos, Georgia, serif; font-size: 16px; line-height: 1.85</code>.</li>
|
||||||
|
<li>Padding: <code>24px 32px</code> for comfortable reading margins.</li>
|
||||||
|
<li><code>[unleserlich]</code> markers: detect via regex <code>/\[unleserlich\]/g</code> and wrap in <code><em class="text-ink-2 italic text-[0.9em]"></code>.</li>
|
||||||
|
<li>Text is <strong>not</strong> contenteditable. No cursor, no selection highlights, no editing.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h4>4b. Scroll sync</h4>
|
||||||
|
<ul>
|
||||||
|
<li>Each paragraph has a click handler that dispatches a <code>highlight-annotation</code> event with the <code>annotation_id</code>.</li>
|
||||||
|
<li>The PDF viewer listens for this event, scrolls to the annotation, and applies a CSS animation (<code>flash-fade</code>, 1.5s ease-out).</li>
|
||||||
|
<li>Reverse direction: clicking a dimmed annotation on the PDF dispatches <code>highlight-paragraph</code> with the <code>annotation_id</code>. The text panel scrolls the matching paragraph into view and applies a background highlight that fades.</li>
|
||||||
|
<li>Use <code>scrollIntoView({ behavior: 'smooth', block: 'center' })</code> for both directions.</li>
|
||||||
|
<li>The highlight CSS animation: <code>background rgba(0,199,177,.10) → transparent</code> over 1.5s.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>5. PDF Annotations in Read Mode</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Turquoise annotation rectangles are rendered but <strong>dimmed</strong>: border opacity 0.3, background opacity 0.04.</li>
|
||||||
|
<li>No numbered badges (the <code>.ann-num</code> elements are hidden via <code>display: none</code>).</li>
|
||||||
|
<li>Annotations remain clickable — they trigger scroll-sync to the matching paragraph.</li>
|
||||||
|
<li>When an annotation is flash-highlighted (via scroll-sync), it briefly returns to full opacity before fading back to dimmed.</li>
|
||||||
|
<li>Yellow comment annotations are not shown in read mode (they belong to annotate mode only).</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>6. Status Bar</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Positioned at the bottom of the text panel (not the full viewport).</li>
|
||||||
|
<li>Content: <code>"{n} Abschnitte · Zuletzt bearbeitet: {userName}, {HH:mm}"</code></li>
|
||||||
|
<li>The “Zuletzt bearbeitet” timestamp is the most recent <code>updated_at</code> across all transcription blocks for this document.</li>
|
||||||
|
<li>The user name comes from the <code>updated_by</code> field of that most recently updated block.</li>
|
||||||
|
<li>i18n keys: <code>transcription_status_sections</code>, <code>transcription_status_last_edited</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>7. Mobile Layout (viewport < 768px)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>The side-by-side split becomes vertical: PDF strip at top, text below.</li>
|
||||||
|
<li>PDF strip collapsed height: <code>70px</code>. Shows a miniature scan preview.</li>
|
||||||
|
<li>Tap strip to expand (~50vh, max 300px). Tap again to collapse. Smooth CSS transition (300ms ease).</li>
|
||||||
|
<li>Expand/collapse hint text in bottom-right corner of the strip.</li>
|
||||||
|
<li>Mode switcher abbreviation: “Lesen | Bearb.” (i18n key: <code>mode_edit_short</code>).</li>
|
||||||
|
<li>Scroll-sync on paragraph tap: if PDF is collapsed, auto-expand first, then scroll to annotation.</li>
|
||||||
|
<li>Text typography: <code>15px</code> (slightly larger than desktop) with <code>line-height: 1.9</code>.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>8. Empty State</h3>
|
||||||
|
<ul>
|
||||||
|
<li>When <code>transcription_blocks</code> count is 0, “Lesen” tab is disabled (<code>opacity: 0.35</code>, <code>cursor: not-allowed</code>, <code>aria-disabled="true"</code>).</li>
|
||||||
|
<li>Mode defaults to <code>'transcribe'</code>.</li>
|
||||||
|
<li>Right panel shows empty state: pencil icon in 48px sand circle, title (“Noch keine Transkription”), description (“Zeichne Bereiche auf dem Scan…”).</li>
|
||||||
|
<li>As soon as the first block is saved, “Lesen” becomes clickable. Mode does not auto-switch.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>9. i18n Keys</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Key</th><th>de</th><th>en</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>mode_read</code></td><td>Lesen</td><td>Read</td></tr>
|
||||||
|
<tr><td><code>mode_edit</code></td><td>Bearbeiten</td><td>Edit</td></tr>
|
||||||
|
<tr><td><code>mode_edit_short</code></td><td>Bearb.</td><td>Edit</td></tr>
|
||||||
|
<tr><td><code>transcription_status_sections</code></td><td>{n} Abschnitte</td><td>{n} sections</td></tr>
|
||||||
|
<tr><td><code>transcription_status_last_edited</code></td><td>Zuletzt bearbeitet: {name}, {time}</td><td>Last edited: {name}, {time}</td></tr>
|
||||||
|
<tr><td><code>transcription_empty_title</code></td><td>Noch keine Transkription</td><td>No transcription yet</td></tr>
|
||||||
|
<tr><td><code>transcription_empty_desc</code></td><td>Zeichne Bereiche auf dem Scan und tippe den Text ab, um eine Transkription zu erstellen.</td><td>Draw regions on the scan and type the text to create a transcription.</td></tr>
|
||||||
|
<tr><td><code>scan_expand</code></td><td>Scan vergrößern</td><td>Expand scan</td></tr>
|
||||||
|
<tr><td><code>scan_collapse</code></td><td>Scan verkleinern</td><td>Collapse scan</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>10. Accessibility</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Mode switcher: <code>role="tablist"</code> with <code>role="tab"</code> children, <code>aria-selected</code> on active tab.</li>
|
||||||
|
<li>Disabled “Lesen” tab: <code>aria-disabled="true"</code>, <code>tabindex="-1"</code>.</li>
|
||||||
|
<li>Read view text: semantic HTML — <code><article></code> wrapping <code><p></code> elements. No <code>contenteditable</code>.</li>
|
||||||
|
<li>Paragraphs are clickable: <code>role="button"</code>, <code>tabindex="0"</code>, <code>aria-label="Abschnitt N — klicken um Scan-Position anzuzeigen"</code>.</li>
|
||||||
|
<li>PDF strip toggle on mobile: <code>role="button"</code>, <code>aria-expanded="{expanded}"</code>, <code>aria-label="Scan {expanded ? 'verkleinern' : 'vergrößern'}"</code>.</li>
|
||||||
|
<li>Scroll-sync animations respect <code>prefers-reduced-motion</code>: skip the 1.5s fade, apply instant highlight that disappears after 200ms.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
frontend/.gitignore
vendored
5
frontend/.gitignore
vendored
@@ -6,6 +6,7 @@ node_modules
|
|||||||
.netlify
|
.netlify
|
||||||
.wrangler
|
.wrangler
|
||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
|
/.svelte-kit-backup
|
||||||
/build
|
/build
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
@@ -29,3 +30,7 @@ src/lib/paraglide
|
|||||||
# (committed as a stub; overwritten by the real spec after generation)
|
# (committed as a stub; overwritten by the real spec after generation)
|
||||||
# src/lib/generated/api.ts
|
# src/lib/generated/api.ts
|
||||||
src/lib/paraglide_bak*
|
src/lib/paraglide_bak*
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
|
||||||
|
e2e/.auth/
|
||||||
|
|||||||
@@ -8,11 +8,18 @@ bun.lockb
|
|||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
/static/
|
/static/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
/.svelte-kit/
|
||||||
|
/.svelte-kit-backup/
|
||||||
|
|
||||||
# Generated files
|
# Generated files
|
||||||
|
/.svelte-kit-backup/
|
||||||
/src/lib/generated/
|
/src/lib/generated/
|
||||||
/src/lib/paraglide/
|
/src/lib/paraglide/
|
||||||
/src/lib/paraglide_bak*/
|
/src/lib/paraglide_bak*/
|
||||||
|
/src/paraglide/
|
||||||
|
|
||||||
# Test artifacts
|
# Test artifacts
|
||||||
/test-results/
|
/test-results/
|
||||||
/e2e/.auth/
|
/e2e/.auth/
|
||||||
|
/coverage/
|
||||||
|
|||||||
31
frontend/.svelte-kit-backup/types/src/routes/persons/[id]/edit/$types.d.ts
vendored
Normal file
31
frontend/.svelte-kit-backup/types/src/routes/persons/[id]/edit/$types.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type * as Kit from '@sveltejs/kit';
|
||||||
|
|
||||||
|
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||||
|
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
|
||||||
|
type RouteParams = { id: string };
|
||||||
|
type RouteId = '/persons/[id]/edit';
|
||||||
|
type MaybeWithVoid<T> = {} extends T ? T | void : T;
|
||||||
|
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
|
||||||
|
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
|
||||||
|
type EnsureDefined<T> = T extends null | undefined ? {} : T;
|
||||||
|
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
|
||||||
|
export type Snapshot<T = any> = Kit.Snapshot<T>;
|
||||||
|
type PageServerParentData = EnsureDefined<import('../../../$types.js').LayoutServerData>;
|
||||||
|
type PageParentData = EnsureDefined<import('../../../$types.js').LayoutData>;
|
||||||
|
|
||||||
|
export type EntryGenerator = () => Promise<Array<RouteParams>> | Array<RouteParams>;
|
||||||
|
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
|
||||||
|
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
|
||||||
|
type ExcludeActionFailure<T> = T extends Kit.ActionFailure<any> ? never : T extends void ? never : T;
|
||||||
|
type ActionsSuccess<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: ExcludeActionFailure<Awaited<ReturnType<T[Key]>>>; }[keyof T];
|
||||||
|
type ExtractActionFailure<T> = T extends Kit.ActionFailure<infer X> ? X extends void ? never : X : never;
|
||||||
|
type ActionsFailure<T extends Record<string, (...args: any) => any>> = { [Key in keyof T]: Exclude<ExtractActionFailure<Awaited<ReturnType<T[Key]>>>, void>; }[keyof T];
|
||||||
|
type ActionsExport = typeof import('../../../../../../../src/routes/persons/[id]/edit/+page.server.js').actions
|
||||||
|
export type SubmitFunction = Kit.SubmitFunction<Expand<ActionsSuccess<ActionsExport>>, Expand<ActionsFailure<ActionsExport>>>
|
||||||
|
export type ActionData = Expand<Kit.AwaitedActions<ActionsExport>> | null;
|
||||||
|
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('../../../../../../../src/routes/persons/[id]/edit/+page.server.js').load>>>>>>;
|
||||||
|
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
|
||||||
|
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
|
||||||
|
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
|
||||||
|
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
|
||||||
|
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"value": "de",
|
"value": "de",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1808896929.897686,
|
"expires": 1809337570.90398,
|
||||||
"httpOnly": false,
|
"httpOnly": false,
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"sameSite": "Lax"
|
"sameSite": "Lax"
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
|
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1774423330.233039,
|
"expires": 1774863971.187596,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
|
|||||||
109
frontend/e2e/accessibility.spec.ts
Normal file
109
frontend/e2e/accessibility.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automated accessibility checks using axe-core (wcag2a + wcag2aa).
|
||||||
|
* Authenticated pages use the stored admin session from playwright.config.ts.
|
||||||
|
* The login page test overrides to an unauthenticated context.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AUTHENTICATED_PAGES = [
|
||||||
|
{ name: 'home', path: '/' },
|
||||||
|
{ name: 'persons', path: '/persons' },
|
||||||
|
{ name: 'admin', path: '/admin' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildAxe(page: Parameters<typeof AxeBuilder>[0]['page']) {
|
||||||
|
return new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Accessibility — authenticated pages', () => {
|
||||||
|
for (const { name, path } of AUTHENTICATED_PAGES) {
|
||||||
|
test(`${name} page has no critical wcag2a/wcag2aa violations`, async ({ page }) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
const results = await buildAxe(page).analyze();
|
||||||
|
|
||||||
|
if (results.violations.length > 0) {
|
||||||
|
const summary = results.violations
|
||||||
|
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||||
|
.join('\n');
|
||||||
|
console.log(`\nAccessibility violations on ${name}:\n${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Accessibility — dark mode (system preference)', () => {
|
||||||
|
for (const { name, path } of AUTHENTICATED_PAGES) {
|
||||||
|
test(`${name} page has no wcag2a/wcag2aa violations in prefers-color-scheme: dark`, async ({
|
||||||
|
browser
|
||||||
|
}) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
colorScheme: 'dark',
|
||||||
|
storageState: 'e2e/.auth/user.json'
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
const results = await buildAxe(page).analyze();
|
||||||
|
|
||||||
|
if (results.violations.length > 0) {
|
||||||
|
const summary = results.violations
|
||||||
|
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||||
|
.join('\n');
|
||||||
|
console.log(`\nAccessibility violations on ${name} (dark/media):\n${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Accessibility — dark mode (manual toggle)', () => {
|
||||||
|
for (const { name, path } of AUTHENTICATED_PAGES) {
|
||||||
|
test(`${name} page has no wcag2a/wcag2aa violations with data-theme='dark'`, async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto(path);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
||||||
|
|
||||||
|
const results = await buildAxe(page).analyze();
|
||||||
|
|
||||||
|
if (results.violations.length > 0) {
|
||||||
|
const summary = results.violations
|
||||||
|
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||||
|
.join('\n');
|
||||||
|
console.log(`\nAccessibility violations on ${name} (dark/manual):\n${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Accessibility — login page', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('login page has no critical wcag2a/wcag2aa violations', async ({ page }) => {
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByLabel('Benutzername')).toBeVisible();
|
||||||
|
|
||||||
|
const results = await buildAxe(page).analyze();
|
||||||
|
|
||||||
|
if (results.violations.length > 0) {
|
||||||
|
const summary = results.violations
|
||||||
|
.map((v) => `[${v.impact}] ${v.id}: ${v.description} (${v.nodes.length} node(s))`)
|
||||||
|
.join('\n');
|
||||||
|
console.log(`\nAccessibility violations on login:\n${summary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classic Split layout — verifies the right column visibility guard.
|
||||||
|
*
|
||||||
|
* The right column (DropZone + NeedsMetadata queue) is only rendered when
|
||||||
|
* `canWrite === true` or there are incomplete docs. A read-only user with a
|
||||||
|
* complete archive must never see an empty 300px ghost column.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('Dashboard Classic Split — write user', () => {
|
||||||
|
test('right column is visible for admin user', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('dashboard-right-column')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Dashboard Classic Split — read-only user', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page, 'reader', 'reader123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('right column is absent for read-only user with no incomplete docs', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('dashboard-right-column')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
62
frontend/e2e/dashboard-screenshots.spec.ts
Normal file
62
frontend/e2e/dashboard-screenshots.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard proofshots — seeds the admin account with test data so every
|
||||||
|
* widget is visible, then captures 6 screenshots (3 viewports × 2 themes).
|
||||||
|
*
|
||||||
|
* Seeded data is removed in afterAll so it doesn't pollute other tests.
|
||||||
|
*/
|
||||||
|
import { test } from '@playwright/test';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import { captureProofshots } from './proofshots';
|
||||||
|
|
||||||
|
// A real document that exists in the dev DB (most recently updated)
|
||||||
|
const SEED_DOC_ID = '24580ce9-9765-40b1-ac59-b0ab15160ce0';
|
||||||
|
const SEED_DOC_TITLE = 'Brief aus dem Krieg';
|
||||||
|
|
||||||
|
// Real comment IDs used as reference_id for deep-linking
|
||||||
|
const COMMENT_IDS = [
|
||||||
|
'46c5171f-1721-4085-a7ed-1eef7b4effb8',
|
||||||
|
'a09cefe4-ddf8-47fa-addc-5c582183b459'
|
||||||
|
];
|
||||||
|
|
||||||
|
const psql = (sql: string) =>
|
||||||
|
execSync(
|
||||||
|
`docker exec archive-db psql -U archive_user family_archive_db -c "${sql.replace(/"/g, '\\"')}"`
|
||||||
|
);
|
||||||
|
|
||||||
|
test.beforeAll(() => {
|
||||||
|
// Insert a MENTION and a REPLY notification for the admin user so the
|
||||||
|
// notifications widget is populated in the screenshots.
|
||||||
|
psql(`
|
||||||
|
INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name)
|
||||||
|
SELECT id, 'MENTION', '${SEED_DOC_ID}', '${COMMENT_IDS[0]}', false, 'Berit Hoffmann'
|
||||||
|
FROM users WHERE username = 'admin';
|
||||||
|
|
||||||
|
INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name)
|
||||||
|
SELECT id, 'REPLY', '${SEED_DOC_ID}', '${COMMENT_IDS[1]}', false, 'Marcel Raddatz'
|
||||||
|
FROM users WHERE username = 'admin';
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(() => {
|
||||||
|
// Remove only the seeded rows (identified by the sentinel actor names)
|
||||||
|
psql(`
|
||||||
|
DELETE FROM notifications
|
||||||
|
WHERE actor_name IN ('Berit Hoffmann', 'Marcel Raddatz')
|
||||||
|
AND recipient_id = (SELECT id FROM users WHERE username = 'admin');
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
captureProofshots('/', 'dashboard', {
|
||||||
|
setup: async (page) => {
|
||||||
|
// Navigate to '/' first so the browser has an origin for localStorage,
|
||||||
|
// then inject the lastVisited entry directly — no document page load needed.
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
await page.evaluate(
|
||||||
|
({ id, title }) => {
|
||||||
|
localStorage.setItem('familienarchiv.lastVisited', JSON.stringify({ id, title }));
|
||||||
|
},
|
||||||
|
{ id: SEED_DOC_ID, title: SEED_DOC_TITLE }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user