Compare commits
34 Commits
4a0d3b3bea
...
feature/56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63013cc86a | ||
|
|
9e2419a48e | ||
|
|
00195dc8db | ||
|
|
0ec86220d3 | ||
|
|
7fbc33b32d | ||
|
|
93f57477cd | ||
|
|
34c66f80fc | ||
|
|
fd03e56c85 | ||
|
|
af57b4e530 | ||
|
|
aaa9286612 | ||
|
|
646674b06a | ||
|
|
1070e6e9ec | ||
|
|
3e5d296b09 | ||
|
|
ee49bac2ef | ||
|
|
48040dc7e4 | ||
|
|
83e5a1fde5 | ||
|
|
37f5c3d005 | ||
|
|
eb8bcdb426 | ||
|
|
05f3ce687f | ||
|
|
06e846f2f8 | ||
|
|
ea1c097ae0 | ||
|
|
b45ec744b2 | ||
|
|
ca5726e7c3 | ||
|
|
0ef81e20f6 | ||
|
|
1ad8fffd1b | ||
|
|
5fb6a1eec0 | ||
|
|
4f69457a68 | ||
|
|
62f62a89a1 | ||
|
|
d84b997965 | ||
|
|
8c86beb9f9 | ||
|
|
0020d1e773 | ||
|
|
47b8cc9340 | ||
|
|
3e65b2feb3 | ||
|
|
f32ed32f67 |
@@ -148,7 +148,7 @@
|
|||||||
<activeByDefault>true</activeByDefault>
|
<activeByDefault>true</activeByDefault>
|
||||||
</activation>
|
</activation>
|
||||||
<properties>
|
<properties>
|
||||||
<spring.profiles.active>dev</spring.profiles.active>
|
<spring.profiles.active>dev,e2e</spring.profiles.active>
|
||||||
</properties>
|
</properties>
|
||||||
</profile>
|
</profile>
|
||||||
<profile>
|
<profile>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public class DataInitializer {
|
|||||||
// 1. Admin Gruppe erstellen
|
// 1. Admin Gruppe erstellen
|
||||||
UserGroup adminGroup = UserGroup.builder()
|
UserGroup adminGroup = UserGroup.builder()
|
||||||
.name("Administrators")
|
.name("Administrators")
|
||||||
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
|
||||||
.build();
|
.build();
|
||||||
groupRepository.save(adminGroup);
|
groupRepository.save(adminGroup);
|
||||||
|
|
||||||
@@ -84,8 +84,24 @@ public class DataInitializer {
|
|||||||
TagRepository tagRepo,
|
TagRepository tagRepo,
|
||||||
PasswordEncoder passwordEncoder) {
|
PasswordEncoder passwordEncoder) {
|
||||||
return args -> {
|
return args -> {
|
||||||
|
// Always ensure the read-only test user exists, even when seed data was already loaded.
|
||||||
|
if (userRepository.findByUsername("reader").isEmpty()) {
|
||||||
|
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
|
||||||
|
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
|
||||||
|
groupRepository.save(UserGroup.builder()
|
||||||
|
.name("Leser")
|
||||||
|
.permissions(Set.of("READ_ALL"))
|
||||||
|
.build()));
|
||||||
|
userRepository.save(AppUser.builder()
|
||||||
|
.username("reader")
|
||||||
|
.password(passwordEncoder.encode("reader123"))
|
||||||
|
.groups(Set.of(leserGroup))
|
||||||
|
.build());
|
||||||
|
log.info("E2E seed: 'reader'-Testbenutzer erstellt.");
|
||||||
|
}
|
||||||
|
|
||||||
if (personRepo.count() > 0) {
|
if (personRepo.count() > 0) {
|
||||||
log.info("E2E seed: Daten bereits vorhanden, überspringe.");
|
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,19 +182,6 @@ public class DataInitializer {
|
|||||||
.receivers(Set.of(otto))
|
.receivers(Set.of(otto))
|
||||||
.build());
|
.build());
|
||||||
|
|
||||||
// ── Read-only user (for permissions E2E tests) ───────────────────
|
|
||||||
// Username: reader / Password: reader123
|
|
||||||
// Has only READ_ALL — used to assert write controls are absent.
|
|
||||||
UserGroup leserGroup = groupRepository.save(UserGroup.builder()
|
|
||||||
.name("Leser")
|
|
||||||
.permissions(Set.of("READ_ALL"))
|
|
||||||
.build());
|
|
||||||
userRepository.save(AppUser.builder()
|
|
||||||
.username("reader")
|
|
||||||
.password(passwordEncoder.encode("reader123"))
|
|
||||||
.groups(Set.of(leserGroup))
|
|
||||||
.build());
|
|
||||||
|
|
||||||
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.",
|
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.",
|
||||||
personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count());
|
personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count());
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package org.raddatz.familienarchiv.controller;
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.BackfillResult;
|
||||||
import org.raddatz.familienarchiv.security.Permission;
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
import org.raddatz.familienarchiv.service.MassImportService;
|
import org.raddatz.familienarchiv.service.MassImportService;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
@@ -18,6 +21,8 @@ import lombok.RequiredArgsConstructor;
|
|||||||
public class AdminController {
|
public class AdminController {
|
||||||
|
|
||||||
private final MassImportService massImportService;
|
private final MassImportService massImportService;
|
||||||
|
private final DocumentService documentService;
|
||||||
|
private final DocumentVersionService documentVersionService;
|
||||||
|
|
||||||
@PostMapping("/trigger-import")
|
@PostMapping("/trigger-import")
|
||||||
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
public ResponseEntity<MassImportService.ImportStatus> triggerMassImport() {
|
||||||
@@ -29,4 +34,17 @@ public class AdminController {
|
|||||||
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
|
public ResponseEntity<MassImportService.ImportStatus> importStatus() {
|
||||||
return ResponseEntity.ok(massImportService.getStatus());
|
return ResponseEntity.ok(massImportService.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/backfill-versions")
|
||||||
|
public ResponseEntity<BackfillResult> backfillVersions() {
|
||||||
|
int count = documentVersionService.backfillMissingVersions(
|
||||||
|
documentService.getDocumentsWithoutVersions());
|
||||||
|
return ResponseEntity.ok(new BackfillResult(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/backfill-file-hashes")
|
||||||
|
public ResponseEntity<BackfillResult> backfillFileHashes() {
|
||||||
|
int count = documentService.backfillFileHashes();
|
||||||
|
return ResponseEntity.ok(new BackfillResult(count));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
|
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}/annotations")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class AnnotationController {
|
||||||
|
|
||||||
|
private final AnnotationService annotationService;
|
||||||
|
private final DocumentService documentService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<DocumentAnnotation> listAnnotations(@PathVariable UUID documentId) {
|
||||||
|
return annotationService.listAnnotations(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentAnnotation createAnnotation(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@RequestBody CreateAnnotationDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
UUID userId = resolveUserId(authentication);
|
||||||
|
Document doc = documentService.getDocumentById(documentId);
|
||||||
|
return annotationService.createAnnotation(documentId, dto, userId, doc.getFileHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{annotationId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public void deleteAnnotation(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID annotationId,
|
||||||
|
Authentication authentication) {
|
||||||
|
UUID userId = resolveUserId(authentication);
|
||||||
|
annotationService.deleteAnnotation(documentId, annotationId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private UUID resolveUserId(Authentication authentication) {
|
||||||
|
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||||
|
try {
|
||||||
|
AppUser user = userService.findByUsername(authentication.getName());
|
||||||
|
return user != null ? user.getId() : null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Could not resolve user for annotation: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateCommentDTO;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.security.Permission;
|
||||||
|
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||||
|
import org.raddatz.familienarchiv.service.CommentService;
|
||||||
|
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
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class CommentController {
|
||||||
|
|
||||||
|
private final CommentService commentService;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
// ─── General document comments ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/api/documents/{documentId}/comments")
|
||||||
|
public List<DocumentComment> getDocumentComments(@PathVariable UUID documentId) {
|
||||||
|
return commentService.getCommentsForDocument(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/comments")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment postDocumentComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.postComment(documentId, null, dto.getContent(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/comments/{commentId}/replies")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment replyToDocumentComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Annotation comments ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GetMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||||
|
public List<DocumentComment> getAnnotationComments(@PathVariable UUID annotationId) {
|
||||||
|
return commentService.getCommentsForAnnotation(annotationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment postAnnotationComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID annotationId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.postComment(documentId, annotationId, dto.getContent(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment replyToAnnotationComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser author = resolveUser(authentication);
|
||||||
|
return commentService.replyToComment(documentId, commentId, dto.getContent(), author);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Edit and delete (shared) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@PatchMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||||
|
@RequirePermission(Permission.ANNOTATE_ALL)
|
||||||
|
public DocumentComment editComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
@RequestBody CreateCommentDTO dto,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser currentUser = resolveUser(authentication);
|
||||||
|
return commentService.editComment(documentId, commentId, dto.getContent(), currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/api/documents/{documentId}/comments/{commentId}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
public void deleteComment(
|
||||||
|
@PathVariable UUID documentId,
|
||||||
|
@PathVariable UUID commentId,
|
||||||
|
Authentication authentication) {
|
||||||
|
AppUser currentUser = resolveUser(authentication);
|
||||||
|
commentService.deleteComment(documentId, commentId, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private AppUser resolveUser(Authentication authentication) {
|
||||||
|
if (authentication == null || !authentication.isAuthenticated()) return null;
|
||||||
|
try {
|
||||||
|
return userService.findByUsername(authentication.getName());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Could not resolve user for comment: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
public record BackfillResult(
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) int count
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class CreateAnnotationDTO {
|
||||||
|
private int pageNumber;
|
||||||
|
private double x;
|
||||||
|
private double y;
|
||||||
|
private double width;
|
||||||
|
private double height;
|
||||||
|
private String color;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class CreateCommentDTO {
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
@@ -38,6 +38,16 @@ public enum ErrorCode {
|
|||||||
/** The password-reset token is missing, expired, or already used. 400 */
|
/** The password-reset token is missing, expired, or already used. 400 */
|
||||||
INVALID_RESET_TOKEN,
|
INVALID_RESET_TOKEN,
|
||||||
|
|
||||||
|
// --- Annotations ---
|
||||||
|
/** The annotation with the given ID does not exist. 404 */
|
||||||
|
ANNOTATION_NOT_FOUND,
|
||||||
|
/** The new annotation overlaps an existing one on the same page. 409 */
|
||||||
|
ANNOTATION_OVERLAP,
|
||||||
|
|
||||||
|
// --- Comments ---
|
||||||
|
/** The comment with the given ID does not exist. 404 */
|
||||||
|
COMMENT_NOT_FOUND,
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
VALIDATION_ERROR,
|
VALIDATION_ERROR,
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ public class Document {
|
|||||||
@Column(name = "content_type")
|
@Column(name = "content_type")
|
||||||
private String contentType;
|
private String contentType;
|
||||||
|
|
||||||
|
// SHA-256 hash of the uploaded file — used to link annotations to a file version
|
||||||
|
@Column(name = "file_hash", length = 64)
|
||||||
|
private String fileHash;
|
||||||
|
|
||||||
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
// Originaler Dateiname beim Upload (z.B. "Brief_Oma_1940.pdf")
|
||||||
@Column(name = "original_filename", nullable = false)
|
@Column(name = "original_filename", nullable = false)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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 = "document_annotations")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class DocumentAnnotation {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "document_id", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID documentId;
|
||||||
|
|
||||||
|
@Column(name = "page_number", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private int pageNumber;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private double x;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private double y;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private double width;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private double height;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String color;
|
||||||
|
|
||||||
|
@Column(name = "file_hash", length = 64)
|
||||||
|
private String fileHash;
|
||||||
|
|
||||||
|
@Column(name = "created_by")
|
||||||
|
private UUID createdBy;
|
||||||
|
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
@CreationTimestamp
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "document_comments")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class DocumentComment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Column(name = "document_id", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private UUID documentId;
|
||||||
|
|
||||||
|
@Column(name = "annotation_id")
|
||||||
|
private UUID annotationId;
|
||||||
|
|
||||||
|
@Column(name = "parent_id")
|
||||||
|
private UUID parentId;
|
||||||
|
|
||||||
|
@Column(name = "author_id")
|
||||||
|
private UUID authorId;
|
||||||
|
|
||||||
|
@Column(name = "author_name", nullable = false)
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String authorName;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
|
||||||
|
// Populated by the service — not stored in the database
|
||||||
|
@Transient
|
||||||
|
@Builder.Default
|
||||||
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
private List<DocumentComment> replies = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface AnnotationRepository extends JpaRepository<DocumentAnnotation, UUID> {
|
||||||
|
|
||||||
|
List<DocumentAnnotation> findByDocumentId(UUID documentId);
|
||||||
|
|
||||||
|
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
|
||||||
|
|
||||||
|
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
|
||||||
|
|
||||||
|
List<DocumentAnnotation> findByDocumentIdAndFileHashIsNull(UUID documentId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public interface CommentRepository extends JpaRepository<DocumentComment, UUID> {
|
||||||
|
|
||||||
|
List<DocumentComment> findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(UUID documentId);
|
||||||
|
|
||||||
|
List<DocumentComment> findByAnnotationIdAndParentIdIsNull(UUID annotationId);
|
||||||
|
|
||||||
|
List<DocumentComment> findByParentId(UUID parentId);
|
||||||
|
}
|
||||||
@@ -34,6 +34,11 @@ public interface DocumentRepository extends JpaRepository<Document, UUID>, JpaSp
|
|||||||
|
|
||||||
List<Document> findByTags_Id(UUID tagId);
|
List<Document> findByTags_Id(UUID tagId);
|
||||||
|
|
||||||
|
@Query("SELECT d FROM Document d WHERE d.id NOT IN (SELECT DISTINCT dv.documentId FROM DocumentVersion dv)")
|
||||||
|
List<Document> findDocumentsWithoutVersions();
|
||||||
|
|
||||||
|
List<Document> findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
|
||||||
@Query("SELECT DISTINCT d FROM Document d " +
|
@Query("SELECT DISTINCT d FROM Document d " +
|
||||||
"JOIN d.receivers r " +
|
"JOIN d.receivers r " +
|
||||||
"WHERE " +
|
"WHERE " +
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.security;
|
|||||||
public enum Permission {
|
public enum Permission {
|
||||||
READ_ALL,
|
READ_ALL,
|
||||||
WRITE_ALL,
|
WRITE_ALL,
|
||||||
|
ANNOTATE_ALL,
|
||||||
ADMIN,
|
ADMIN,
|
||||||
ADMIN_USER,
|
ADMIN_USER,
|
||||||
ADMIN_TAG,
|
ADMIN_TAG,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
|
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AnnotationService {
|
||||||
|
|
||||||
|
private final AnnotationRepository annotationRepository;
|
||||||
|
|
||||||
|
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
|
||||||
|
return annotationRepository.findByDocumentId(documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId, String fileHash) {
|
||||||
|
List<DocumentAnnotation> existing =
|
||||||
|
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
|
||||||
|
|
||||||
|
boolean overlaps = existing.stream().anyMatch(a -> overlaps(a, dto));
|
||||||
|
if (overlaps) {
|
||||||
|
throw DomainException.conflict(
|
||||||
|
ErrorCode.ANNOTATION_OVERLAP, "Annotation overlaps an existing one on this page");
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentAnnotation annotation = DocumentAnnotation.builder()
|
||||||
|
.documentId(documentId)
|
||||||
|
.pageNumber(dto.getPageNumber())
|
||||||
|
.x(dto.getX())
|
||||||
|
.y(dto.getY())
|
||||||
|
.width(dto.getWidth())
|
||||||
|
.height(dto.getHeight())
|
||||||
|
.color(dto.getColor())
|
||||||
|
.fileHash(fileHash)
|
||||||
|
.createdBy(userId)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return annotationRepository.save(annotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) {
|
||||||
|
DocumentAnnotation annotation = annotationRepository
|
||||||
|
.findByIdAndDocumentId(annotationId, documentId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId));
|
||||||
|
|
||||||
|
if (userId == null || !userId.equals(annotation.getCreatedBy())) {
|
||||||
|
throw DomainException.forbidden("Only the annotation author can delete it");
|
||||||
|
}
|
||||||
|
|
||||||
|
annotationRepository.delete(annotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void backfillAnnotationFileHashForDocument(UUID documentId, String fileHash) {
|
||||||
|
annotationRepository.findByDocumentIdAndFileHashIsNull(documentId).forEach(a -> {
|
||||||
|
a.setFileHash(fileHash);
|
||||||
|
annotationRepository.save(a);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
|
||||||
|
double ex2 = existing.getX() + existing.getWidth();
|
||||||
|
double ey2 = existing.getY() + existing.getHeight();
|
||||||
|
double dx2 = dto.getX() + dto.getWidth();
|
||||||
|
double dy2 = dto.getY() + dto.getHeight();
|
||||||
|
return existing.getX() < dx2 && ex2 > dto.getX()
|
||||||
|
&& existing.getY() < dy2 && ey2 > dto.getY();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
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.repository.CommentRepository;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CommentService {
|
||||||
|
|
||||||
|
private final CommentRepository commentRepository;
|
||||||
|
|
||||||
|
public List<DocumentComment> getCommentsForDocument(UUID documentId) {
|
||||||
|
List<DocumentComment> roots =
|
||||||
|
commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(documentId);
|
||||||
|
return withReplies(roots);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DocumentComment> getCommentsForAnnotation(UUID annotationId) {
|
||||||
|
List<DocumentComment> roots = commentRepository.findByAnnotationIdAndParentIdIsNull(annotationId);
|
||||||
|
return withReplies(roots);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DocumentComment postComment(UUID documentId, UUID annotationId, String content, AppUser author) {
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.documentId(documentId)
|
||||||
|
.annotationId(annotationId)
|
||||||
|
.content(content)
|
||||||
|
.authorId(author.getId())
|
||||||
|
.authorName(resolveAuthorName(author))
|
||||||
|
.build();
|
||||||
|
return commentRepository.save(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DocumentComment replyToComment(UUID documentId, UUID commentId, String content, AppUser author) {
|
||||||
|
DocumentComment target = commentRepository.findById(commentId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
||||||
|
|
||||||
|
UUID rootId = target.getParentId() != null ? target.getParentId() : target.getId();
|
||||||
|
DocumentComment root = commentRepository.findById(rootId)
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + rootId));
|
||||||
|
|
||||||
|
DocumentComment reply = DocumentComment.builder()
|
||||||
|
.documentId(documentId)
|
||||||
|
.annotationId(root.getAnnotationId())
|
||||||
|
.parentId(root.getId())
|
||||||
|
.content(content)
|
||||||
|
.authorId(author.getId())
|
||||||
|
.authorName(resolveAuthorName(author))
|
||||||
|
.build();
|
||||||
|
return commentRepository.save(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public DocumentComment editComment(UUID documentId, UUID commentId, String content, AppUser currentUser) {
|
||||||
|
DocumentComment comment = findComment(documentId, commentId);
|
||||||
|
if (!currentUser.getId().equals(comment.getAuthorId())) {
|
||||||
|
throw DomainException.forbidden("Only the comment author can edit it");
|
||||||
|
}
|
||||||
|
comment.setContent(content);
|
||||||
|
return commentRepository.save(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteComment(UUID documentId, UUID commentId, AppUser currentUser) {
|
||||||
|
DocumentComment comment = findComment(documentId, commentId);
|
||||||
|
boolean isAuthor = currentUser.getId().equals(comment.getAuthorId());
|
||||||
|
boolean isAdmin = currentUser.hasPermission("ADMIN");
|
||||||
|
if (!isAuthor && !isAdmin) {
|
||||||
|
throw DomainException.forbidden("Only the comment author or an admin can delete it");
|
||||||
|
}
|
||||||
|
commentRepository.delete(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private List<DocumentComment> withReplies(List<DocumentComment> roots) {
|
||||||
|
roots.forEach(root -> root.setReplies(commentRepository.findByParentId(root.getId())));
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
|
private DocumentComment findComment(UUID documentId, UUID commentId) {
|
||||||
|
return commentRepository.findById(commentId)
|
||||||
|
.filter(c -> documentId.equals(c.getDocumentId()))
|
||||||
|
.orElseThrow(() -> DomainException.notFound(
|
||||||
|
ErrorCode.COMMENT_NOT_FOUND, "Comment not found: " + commentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveAuthorName(AppUser author) {
|
||||||
|
String first = author.getFirstName();
|
||||||
|
String last = author.getLastName();
|
||||||
|
if ((first == null || first.isBlank()) && (last == null || last.isBlank())) {
|
||||||
|
return author.getUsername();
|
||||||
|
}
|
||||||
|
return ((first != null ? first : "") + " " + (last != null ? last : "")).strip();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
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;
|
||||||
@@ -38,6 +40,7 @@ public class DocumentService {
|
|||||||
private final FileService fileService;
|
private final FileService fileService;
|
||||||
private final TagService tagService;
|
private final TagService tagService;
|
||||||
private final DocumentVersionService documentVersionService;
|
private final DocumentVersionService documentVersionService;
|
||||||
|
private final AnnotationService annotationService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lädt eine Datei hoch.
|
* Lädt eine Datei hoch.
|
||||||
@@ -64,10 +67,11 @@ public class DocumentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delegate Storage to FileService
|
// 2. Delegate Storage to FileService
|
||||||
String s3Key = fileService.uploadFile(file, originalFilename);
|
FileService.UploadResult upload = fileService.uploadFile(file, originalFilename);
|
||||||
|
|
||||||
// 3. Update Database
|
// 3. Update Database
|
||||||
document.setFilePath(s3Key);
|
document.setFilePath(upload.s3Key());
|
||||||
|
document.setFileHash(upload.fileHash());
|
||||||
document.setContentType(file.getContentType());
|
document.setContentType(file.getContentType());
|
||||||
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
|
if (document.getStatus() == DocumentStatus.PLACEHOLDER) {
|
||||||
document.setStatus(DocumentStatus.UPLOADED);
|
document.setStatus(DocumentStatus.UPLOADED);
|
||||||
@@ -120,8 +124,9 @@ public class DocumentService {
|
|||||||
|
|
||||||
// Datei
|
// Datei
|
||||||
if (file != null && !file.isEmpty()) {
|
if (file != null && !file.isEmpty()) {
|
||||||
String s3Key = fileService.uploadFile(file, file.getOriginalFilename());
|
FileService.UploadResult upload = fileService.uploadFile(file, file.getOriginalFilename());
|
||||||
doc.setFilePath(s3Key);
|
doc.setFilePath(upload.s3Key());
|
||||||
|
doc.setFileHash(upload.fileHash());
|
||||||
doc.setContentType(file.getContentType());
|
doc.setContentType(file.getContentType());
|
||||||
doc.setStatus(DocumentStatus.UPLOADED);
|
doc.setStatus(DocumentStatus.UPLOADED);
|
||||||
}
|
}
|
||||||
@@ -170,12 +175,9 @@ public class DocumentService {
|
|||||||
|
|
||||||
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
// 4. Datei austauschen (nur wenn eine neue ausgewählt wurde)
|
||||||
if (newFile != null && !newFile.isEmpty()) {
|
if (newFile != null && !newFile.isEmpty()) {
|
||||||
// Alte Datei könnte man hier theoretisch löschen (optional)
|
FileService.UploadResult upload = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
||||||
|
doc.setFilePath(upload.s3Key());
|
||||||
// Neue Datei hochladen
|
doc.setFileHash(upload.fileHash());
|
||||||
String s3Key = fileService.uploadFile(newFile, newFile.getOriginalFilename());
|
|
||||||
|
|
||||||
doc.setFilePath(s3Key);
|
|
||||||
doc.setOriginalFilename(newFile.getOriginalFilename());
|
doc.setOriginalFilename(newFile.getOriginalFilename());
|
||||||
doc.setContentType(newFile.getContentType());
|
doc.setContentType(newFile.getContentType());
|
||||||
doc.setStatus(DocumentStatus.UPLOADED);
|
doc.setStatus(DocumentStatus.UPLOADED);
|
||||||
@@ -257,6 +259,10 @@ public class DocumentService {
|
|||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Document> getDocumentsWithoutVersions() {
|
||||||
|
return documentRepository.findDocumentsWithoutVersions();
|
||||||
|
}
|
||||||
|
|
||||||
public List<Document> getDocumentsBySender(UUID senderId) {
|
public List<Document> getDocumentsBySender(UUID senderId) {
|
||||||
return documentRepository.findBySenderId(senderId);
|
return documentRepository.findBySenderId(senderId);
|
||||||
}
|
}
|
||||||
@@ -279,4 +285,39 @@ public class DocumentService {
|
|||||||
});
|
});
|
||||||
tagService.delete(tagId);
|
tagService.delete(tagId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public int backfillFileHashes() {
|
||||||
|
List<Document> docs = documentRepository.findByFileHashIsNullAndFilePathIsNotNull();
|
||||||
|
int count = 0;
|
||||||
|
for (Document doc : docs) {
|
||||||
|
try {
|
||||||
|
byte[] bytes = fileService.downloadFileBytes(doc.getFilePath());
|
||||||
|
String hash = sha256Hex(bytes);
|
||||||
|
doc.setFileHash(hash);
|
||||||
|
documentRepository.save(doc);
|
||||||
|
annotationService.backfillAnnotationFileHashForDocument(doc.getId(), hash);
|
||||||
|
count++;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to backfill hash for document {}: {}", doc.getId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String sha256Hex(byte[] bytes) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(bytes);
|
||||||
|
StringBuilder sb = new StringBuilder(64);
|
||||||
|
for (byte b : hash) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 not available", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ public class DocumentVersionService {
|
|||||||
.build());
|
.build());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public int backfillMissingVersions(List<Document> docs) {
|
||||||
|
int count = 0;
|
||||||
|
for (Document doc : docs) {
|
||||||
|
List<DocumentVersion> existing = versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId());
|
||||||
|
if (!existing.isEmpty()) continue;
|
||||||
|
LocalDateTime savedAt = doc.getCreatedAt() != null ? doc.getCreatedAt() : LocalDateTime.now();
|
||||||
|
versionRepository.save(DocumentVersion.builder()
|
||||||
|
.documentId(doc.getId())
|
||||||
|
.savedAt(savedAt)
|
||||||
|
.editorId(null)
|
||||||
|
.editorName("Datenimport")
|
||||||
|
.snapshot(serializeSnapshot(doc))
|
||||||
|
.changedFields("[]")
|
||||||
|
.build());
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
public List<DocumentVersionSummary> getSummaries(UUID documentId) {
|
public List<DocumentVersionSummary> getSummaries(UUID documentId) {
|
||||||
return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream()
|
return versionRepository.findByDocumentIdOrderBySavedAtAsc(documentId).stream()
|
||||||
.map(v -> new DocumentVersionSummary(
|
.map(v -> new DocumentVersionSummary(
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import org.springframework.core.io.InputStreamResource;
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -29,10 +32,14 @@ public class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uploads a file to S3/MinIO and returns the generated object key.
|
* Uploads a file to S3/MinIO.
|
||||||
|
* Returns an {@link UploadResult} containing the S3 key and the SHA-256
|
||||||
|
* hash of the file content. The hash is used to link annotations to the
|
||||||
|
* specific file version they were created against.
|
||||||
*/
|
*/
|
||||||
public String uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
public UploadResult uploadFile(MultipartFile file, String originalFilename) throws IOException {
|
||||||
// Generate secure unique path: "documents/UUID_filename"
|
byte[] bytes = file.getBytes();
|
||||||
|
String fileHash = sha256Hex(bytes);
|
||||||
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
String s3Key = "documents/" + UUID.randomUUID() + "_" + originalFilename;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -42,11 +49,10 @@ public class FileService {
|
|||||||
.contentType(file.getContentType())
|
.contentType(file.getContentType())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
s3Client.putObject(putObjectRequest,
|
s3Client.putObject(putObjectRequest, RequestBody.fromBytes(bytes));
|
||||||
RequestBody.fromInputStream(file.getInputStream(), file.getSize()));
|
|
||||||
|
|
||||||
log.info("Uploaded file to S3: {}", s3Key);
|
log.info("Uploaded file to S3: {} (hash={})", s3Key, fileHash);
|
||||||
return s3Key;
|
return new UploadResult(s3Key, fileHash);
|
||||||
} catch (S3Exception e) {
|
} catch (S3Exception e) {
|
||||||
log.error("S3 Upload Error", e);
|
log.error("S3 Upload Error", e);
|
||||||
throw new IOException("Failed to upload file to storage", e);
|
throw new IOException("Failed to upload file to storage", e);
|
||||||
@@ -58,32 +64,72 @@ public class FileService {
|
|||||||
* Returns a wrapper containing the stream and content type.
|
* Returns a wrapper containing the stream and content type.
|
||||||
*/
|
*/
|
||||||
public S3FileDownload downloadFile(String s3Key) {
|
public S3FileDownload downloadFile(String s3Key) {
|
||||||
try {
|
try {
|
||||||
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||||
.bucket(bucketName)
|
.bucket(bucketName)
|
||||||
.key(s3Key)
|
.key(s3Key)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
ResponseInputStream<GetObjectResponse> s3Object = s3Client.getObject(getObjectRequest);
|
||||||
|
|
||||||
// Use whatever content type S3 has stored (set at upload time)
|
String contentType = s3Object.response().contentType();
|
||||||
String contentType = s3Object.response().contentType();
|
if (contentType == null || contentType.isBlank()) {
|
||||||
if (contentType == null || contentType.isBlank()) {
|
contentType = "application/octet-stream";
|
||||||
contentType = "application/octet-stream";
|
}
|
||||||
|
|
||||||
|
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
|
||||||
|
|
||||||
|
} catch (NoSuchKeyException e) {
|
||||||
|
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||||
|
} catch (S3Exception e) {
|
||||||
|
throw new RuntimeException("Storage Error: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
return new S3FileDownload(new InputStreamResource(s3Object), contentType);
|
|
||||||
|
|
||||||
} catch (NoSuchKeyException e) {
|
|
||||||
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
|
||||||
} catch (S3Exception e) {
|
|
||||||
throw new RuntimeException("Storage Error: " + e.getMessage());
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// Helper Record to carry the stream and metadata back to the controller
|
/**
|
||||||
|
* Downloads a file from S3/MinIO and returns its raw bytes.
|
||||||
|
* Used for hash backfill — callers are responsible for not calling this on large files unnecessarily.
|
||||||
|
*/
|
||||||
|
public byte[] downloadFileBytes(String s3Key) throws IOException {
|
||||||
|
try {
|
||||||
|
GetObjectRequest getObjectRequest = GetObjectRequest.builder()
|
||||||
|
.bucket(bucketName)
|
||||||
|
.key(s3Key)
|
||||||
|
.build();
|
||||||
|
try (InputStream in = s3Client.getObject(getObjectRequest)) {
|
||||||
|
return in.readAllBytes();
|
||||||
|
}
|
||||||
|
} catch (NoSuchKeyException e) {
|
||||||
|
throw new StorageFileNotFoundException("File not found in storage: " + s3Key);
|
||||||
|
} catch (S3Exception e) {
|
||||||
|
throw new IOException("Failed to download file from storage: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── private helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static String sha256Hex(byte[] bytes) {
|
||||||
|
try {
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(bytes);
|
||||||
|
StringBuilder sb = new StringBuilder(64);
|
||||||
|
for (byte b : hash) {
|
||||||
|
sb.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException("SHA-256 not available", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── result types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Carries the S3 object key and the content hash back to the caller. */
|
||||||
|
public record UploadResult(String s3Key, String fileHash) {}
|
||||||
|
|
||||||
|
/** Carries the download stream and content type. */
|
||||||
public record S3FileDownload(InputStreamResource resource, String contentType) {}
|
public record S3FileDownload(InputStreamResource resource, String contentType) {}
|
||||||
|
|
||||||
// Custom Exception
|
|
||||||
public static class StorageFileNotFoundException extends RuntimeException {
|
public static class StorageFileNotFoundException extends RuntimeException {
|
||||||
public StorageFileNotFoundException(String message) { super(message); }
|
public StorageFileNotFoundException(String message) { super(message); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE document_annotations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
page_number INTEGER NOT NULL,
|
||||||
|
x DOUBLE PRECISION NOT NULL,
|
||||||
|
y DOUBLE PRECISION NOT NULL,
|
||||||
|
width DOUBLE PRECISION NOT NULL,
|
||||||
|
height DOUBLE PRECISION NOT NULL,
|
||||||
|
color VARCHAR(20) NOT NULL,
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ON document_annotations (document_id, page_number);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Grant ANNOTATE_ALL to every group that already has ADMIN.
|
||||||
|
-- New installs get it via DataInitializer; this covers existing deployments.
|
||||||
|
INSERT INTO group_permissions (group_id, permission)
|
||||||
|
SELECT g.id, 'ANNOTATE_ALL'
|
||||||
|
FROM user_groups g
|
||||||
|
WHERE g.id IN (SELECT group_id FROM group_permissions WHERE permission = 'ADMIN')
|
||||||
|
AND g.id NOT IN (SELECT group_id FROM group_permissions WHERE permission = 'ANNOTATE_ALL');
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE document_comments (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
annotation_id UUID REFERENCES document_annotations(id) ON DELETE CASCADE,
|
||||||
|
parent_id UUID REFERENCES document_comments(id) ON DELETE CASCADE,
|
||||||
|
author_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
author_name VARCHAR(200) NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_dc_document ON document_comments(document_id);
|
||||||
|
CREATE INDEX idx_dc_annotation ON document_comments(annotation_id);
|
||||||
|
CREATE INDEX idx_dc_parent ON document_comments(parent_id);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add content-based file hash to documents for annotation versioning
|
||||||
|
ALTER TABLE documents
|
||||||
|
ADD COLUMN file_hash VARCHAR(64);
|
||||||
|
|
||||||
|
-- Each annotation remembers which file version it was created against
|
||||||
|
ALTER TABLE document_annotations
|
||||||
|
ADD COLUMN file_hash VARCHAR(64);
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||||
|
import org.raddatz.familienarchiv.service.MassImportService;
|
||||||
|
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 static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(AdminController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class AdminControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean MassImportService massImportService;
|
||||||
|
@MockitoBean DocumentService documentService;
|
||||||
|
@MockitoBean DocumentVersionService documentVersionService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillVersions_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(roles = "USER")
|
||||||
|
void backfillVersions_returns403_whenNotAdmin() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ADMIN")
|
||||||
|
void backfillVersions_returns200_withCount_whenAdmin() throws Exception {
|
||||||
|
when(documentService.getDocumentsWithoutVersions()).thenReturn(List.of(Document.builder().build()));
|
||||||
|
when(documentVersionService.backfillMissingVersions(anyList())).thenReturn(1);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-versions"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.count").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/admin/backfill-file-hashes ──────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(roles = "USER")
|
||||||
|
void backfillFileHashes_returns403_whenNotAdmin() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ADMIN")
|
||||||
|
void backfillFileHashes_returns200_withCount_whenAdmin() throws Exception {
|
||||||
|
when(documentService.backfillFileHashes()).thenReturn(3);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/admin/backfill-file-hashes"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.count").value(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
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.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.AnnotationService;
|
||||||
|
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
|
||||||
|
import org.raddatz.familienarchiv.service.DocumentService;
|
||||||
|
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.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.util.List;
|
||||||
|
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.delete;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(AnnotationController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class AnnotationControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean AnnotationService annotationService;
|
||||||
|
@MockitoBean DocumentService documentService;
|
||||||
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
private static final String ANNOTATION_JSON =
|
||||||
|
"{\"pageNumber\":1,\"x\":0.1,\"y\":0.1,\"width\":0.2,\"height\":0.2,\"color\":\"#ff0000\"}";
|
||||||
|
|
||||||
|
// ─── GET /api/documents/{documentId}/annotations ──────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listAnnotations_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void listAnnotations_returns200_whenAuthenticated() throws Exception {
|
||||||
|
when(annotationService.listAnnotations(any())).thenReturn(List.of());
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/{documentId}/annotations ─────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(ANNOTATION_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(ANNOTATION_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void createAnnotation_returns201_whenHasAnnotatePermission() 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())
|
||||||
|
.andExpect(jsonPath("$.pageNumber").value(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void createAnnotation_returns409_whenOverlap() throws Exception {
|
||||||
|
when(documentService.getDocumentById(any())).thenReturn(Document.builder().build());
|
||||||
|
when(annotationService.createAnnotation(any(), any(), any(), any()))
|
||||||
|
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(ANNOTATION_JSON))
|
||||||
|
.andExpect(status().isConflict());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/documents/{documentId}/annotations/{annotationId} ─────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.raddatz.familienarchiv.config.SecurityConfig;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.security.PermissionAspect;
|
||||||
|
import org.raddatz.familienarchiv.service.CommentService;
|
||||||
|
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.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.util.List;
|
||||||
|
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.delete;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(CommentController.class)
|
||||||
|
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
|
||||||
|
class CommentControllerTest {
|
||||||
|
|
||||||
|
@Autowired MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean CommentService commentService;
|
||||||
|
@MockitoBean UserService userService;
|
||||||
|
@MockitoBean CustomUserDetailsService customUserDetailsService;
|
||||||
|
|
||||||
|
private static final String COMMENT_JSON = "{\"content\":\"Test comment\"}";
|
||||||
|
private static final UUID DOC_ID = UUID.randomUUID();
|
||||||
|
private static final UUID ANN_ID = UUID.randomUUID();
|
||||||
|
private static final UUID COMMENT_ID = UUID.randomUUID();
|
||||||
|
|
||||||
|
// ─── GET /api/documents/{documentId}/comments ─────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getDocumentComments_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getDocumentComments_returns200_whenAuthenticated() throws Exception {
|
||||||
|
when(commentService.getCommentsForDocument(any())).thenReturn(List.of());
|
||||||
|
mockMvc.perform(get("/api/documents/" + DOC_ID + "/comments"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/{documentId}/comments ────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postDocumentComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void postDocumentComment_returns403_whenMissingPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void postDocumentComment_returns201_whenHasPermission() 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())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.content").value("Test comment"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/{documentId}/comments/{commentId}/replies ────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void replyToComment_returns201_whenHasPermission() 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())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID + "/replies")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── PATCH /api/documents/{documentId}/comments/{commentId} ──────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(patch("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void editComment_returns200_whenHasPermission() 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/documents/{documentId}/comments/{commentId} ─────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteComment_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void deleteComment_returns204_whenAuthenticated() throws Exception {
|
||||||
|
mockMvc.perform(delete("/api/documents/" + DOC_ID + "/comments/" + COMMENT_ID))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/documents/{documentId}/annotations/{annId}/comments ─────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAnnotationComments_returns401_whenUnauthenticated() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||||
|
.andExpect(status().isUnauthorized());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void getAnnotationComments_returns200_whenAuthenticated() throws Exception {
|
||||||
|
when(commentService.getCommentsForAnnotation(any())).thenReturn(List.of());
|
||||||
|
mockMvc.perform(get("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments"))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments ────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser
|
||||||
|
void postAnnotationComment_returns403_whenMissingPermission() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void postAnnotationComment_returns201_whenHasPermission() 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())).thenReturn(saved);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/documents/" + DOC_ID + "/annotations/" + ANN_ID + "/comments")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(COMMENT_JSON))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/documents/{documentId}/annotations/{annId}/comments/{commentId}/replies ─
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@WithMockUser(authorities = "ANNOTATE_ALL")
|
||||||
|
void replyToAnnotationComment_returns201_whenHasPermission() 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())).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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
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.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
|
import org.raddatz.familienarchiv.repository.AnnotationRepository;
|
||||||
|
|
||||||
|
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.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.http.HttpStatus.CONFLICT;
|
||||||
|
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||||
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AnnotationServiceTest {
|
||||||
|
|
||||||
|
@Mock AnnotationRepository annotationRepository;
|
||||||
|
@InjectMocks AnnotationService annotationService;
|
||||||
|
|
||||||
|
// ─── createAnnotation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_throwsConflict_whenAnnotationOverlapsExisting() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.3, 0.3, "#ff0000");
|
||||||
|
|
||||||
|
DocumentAnnotation existing = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
|
.x(0.2).y(0.2).width(0.3).height(0.3).color("#00ff00").build();
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
|
||||||
|
.thenReturn(List.of(existing));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId, null))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
|
||||||
|
|
||||||
|
verify(annotationRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_savesAndReturns_whenNoOverlap() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||||
|
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||||
|
DocumentAnnotation saved = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
|
||||||
|
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
|
||||||
|
when(annotationRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
||||||
|
|
||||||
|
assertThat(result).isEqualTo(saved);
|
||||||
|
verify(annotationRepository).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── deleteAnnotation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteAnnotation_throwsNotFound_whenMissing() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID annotId = UUID.randomUUID();
|
||||||
|
when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, UUID.randomUUID()))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteAnnotation_throwsForbidden_whenNotOwner() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID annotId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
UUID otherId = 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, otherId))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||||
|
|
||||||
|
verify(annotationRepository, never()).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteAnnotation_succeeds_whenOwner() {
|
||||||
|
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));
|
||||||
|
|
||||||
|
annotationService.deleteAnnotation(docId, annotId, ownerId);
|
||||||
|
|
||||||
|
verify(annotationRepository).delete(annotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_setsFileHash_whenProvided() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||||
|
String fileHash = "abc123";
|
||||||
|
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||||
|
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, fileHash);
|
||||||
|
|
||||||
|
assertThat(result.getFileHash()).isEqualTo(fileHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createAnnotation_setsNullFileHash_whenNoneProvided() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID userId = UUID.randomUUID();
|
||||||
|
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
|
||||||
|
|
||||||
|
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
|
||||||
|
when(annotationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId, null);
|
||||||
|
|
||||||
|
assertThat(result.getFileHash()).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── listAnnotations ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listAnnotations_returnsAllForDocument() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
DocumentAnnotation a = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).build();
|
||||||
|
when(annotationRepository.findByDocumentId(docId)).thenReturn(List.of(a));
|
||||||
|
|
||||||
|
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── backfillAnnotationFileHashForDocument ────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillAnnotationFileHashForDocument_setsHashOnAnnotationsWithNullHash() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
String hash = "abc123";
|
||||||
|
DocumentAnnotation a = DocumentAnnotation.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).build();
|
||||||
|
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of(a));
|
||||||
|
|
||||||
|
annotationService.backfillAnnotationFileHashForDocument(docId, hash);
|
||||||
|
|
||||||
|
assertThat(a.getFileHash()).isEqualTo(hash);
|
||||||
|
verify(annotationRepository).save(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillAnnotationFileHashForDocument_doesNothingWhenNoAnnotations() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
when(annotationRepository.findByDocumentIdAndFileHashIsNull(docId)).thenReturn(List.of());
|
||||||
|
|
||||||
|
annotationService.backfillAnnotationFileHashForDocument(docId, "hash");
|
||||||
|
|
||||||
|
verify(annotationRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
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.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.model.AppUser;
|
||||||
|
import org.raddatz.familienarchiv.model.DocumentComment;
|
||||||
|
import org.raddatz.familienarchiv.model.UserGroup;
|
||||||
|
import org.raddatz.familienarchiv.repository.CommentRepository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
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.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.http.HttpStatus.FORBIDDEN;
|
||||||
|
import static org.springframework.http.HttpStatus.NOT_FOUND;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class CommentServiceTest {
|
||||||
|
|
||||||
|
@Mock CommentRepository commentRepository;
|
||||||
|
@InjectMocks CommentService commentService;
|
||||||
|
|
||||||
|
// ─── postComment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_capturesAuthorNameAtWriteTime() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder()
|
||||||
|
.id(UUID.randomUUID()).username("hans").firstName("Hans").lastName("Müller").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("Hans Müller").content("Test").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("Hans Müller");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void postComment_fallsBackToUsername_whenNamesAreBlank() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("hans42").build();
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).authorName("hans42").content("Test").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.postComment(docId, null, "Test", author);
|
||||||
|
|
||||||
|
assertThat(result.getAuthorName()).isEqualTo("hans42");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── replyToComment ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_throwsNotFound_whenTargetCommentMissing() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(UUID.randomUUID()).username("anna").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> commentService.replyToComment(docId, commentId, "Reply", author))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
|
||||||
|
|
||||||
|
verify(commentRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_resolvesToRootParent_whenReplyingToAReply() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
UUID replyId = 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 existingReply = DocumentComment.builder()
|
||||||
|
.id(replyId).documentId(docId).parentId(rootId).content("Reply1").authorName("Anna").build();
|
||||||
|
|
||||||
|
when(commentRepository.findById(replyId)).thenReturn(Optional.of(existingReply));
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply2").authorName("anna").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.replyToComment(docId, replyId, "Reply2", author);
|
||||||
|
|
||||||
|
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replyToComment_usesDirectComment_whenReplyingToTopLevel() {
|
||||||
|
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();
|
||||||
|
|
||||||
|
when(commentRepository.findById(rootId)).thenReturn(Optional.of(root));
|
||||||
|
DocumentComment saved = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).content("Reply").authorName("anna").build();
|
||||||
|
when(commentRepository.save(any())).thenReturn(saved);
|
||||||
|
|
||||||
|
DocumentComment result = commentService.replyToComment(docId, rootId, "Reply", author);
|
||||||
|
|
||||||
|
assertThat(result.getParentId()).isEqualTo(rootId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── editComment ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editComment_throwsForbidden_whenNotAuthor() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(ownerId).content("Original").authorName("Hans").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> commentService.editComment(docId, commentId, "Changed", other))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||||
|
|
||||||
|
verify(commentRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void editComment_updatesContent_whenAuthor() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID authorId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(authorId).username("hans").build();
|
||||||
|
LocalDateTime created = LocalDateTime.now().minusMinutes(5);
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(authorId)
|
||||||
|
.content("Original").authorName("Hans").createdAt(created).build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
when(commentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
DocumentComment result = commentService.editComment(docId, commentId, "Updated", author);
|
||||||
|
|
||||||
|
assertThat(result.getContent()).isEqualTo("Updated");
|
||||||
|
assertThat(result.getCreatedAt()).isEqualTo(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── deleteComment ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteComment_throwsForbidden_whenNotAuthorAndNotAdmin() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
AppUser other = AppUser.builder().id(UUID.randomUUID()).username("other").build();
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> commentService.deleteComment(docId, commentId, other))
|
||||||
|
.isInstanceOf(DomainException.class)
|
||||||
|
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
|
||||||
|
|
||||||
|
verify(commentRepository, never()).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteComment_succeeds_whenAuthor() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID authorId = UUID.randomUUID();
|
||||||
|
AppUser author = AppUser.builder().id(authorId).username("hans").build();
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(authorId).authorName("Hans").content("X").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
|
||||||
|
commentService.deleteComment(docId, commentId, author);
|
||||||
|
|
||||||
|
verify(commentRepository).delete(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteComment_succeeds_whenAdmin() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID commentId = UUID.randomUUID();
|
||||||
|
UUID ownerId = UUID.randomUUID();
|
||||||
|
AppUser admin = buildAdmin();
|
||||||
|
|
||||||
|
DocumentComment comment = DocumentComment.builder()
|
||||||
|
.id(commentId).documentId(docId).authorId(ownerId).authorName("Hans").content("X").build();
|
||||||
|
when(commentRepository.findById(commentId)).thenReturn(Optional.of(comment));
|
||||||
|
|
||||||
|
commentService.deleteComment(docId, commentId, admin);
|
||||||
|
|
||||||
|
verify(commentRepository).delete(comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── getCommentsForDocument ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getCommentsForDocument_returnsRootsWithRepliesAttached() {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
UUID rootId = UUID.randomUUID();
|
||||||
|
|
||||||
|
DocumentComment root = DocumentComment.builder()
|
||||||
|
.id(rootId).documentId(docId).authorName("Hans").content("Root").build();
|
||||||
|
DocumentComment reply = DocumentComment.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(docId).parentId(rootId).authorName("Anna").content("Reply").build();
|
||||||
|
|
||||||
|
when(commentRepository.findByDocumentIdAndAnnotationIdIsNullAndParentIdIsNull(docId))
|
||||||
|
.thenReturn(List.of(root));
|
||||||
|
when(commentRepository.findByParentId(rootId)).thenReturn(List.of(reply));
|
||||||
|
|
||||||
|
List<DocumentComment> result = commentService.getCommentsForDocument(docId);
|
||||||
|
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.get(0).getReplies()).containsExactly(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private AppUser buildAdmin() {
|
||||||
|
return AppUser.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.username("admin")
|
||||||
|
.groups(Set.of(UserGroup.builder()
|
||||||
|
.id(UUID.randomUUID())
|
||||||
|
.name("admins")
|
||||||
|
.permissions(Set.of("ADMIN"))
|
||||||
|
.build()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ 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.eq;
|
||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
@@ -31,6 +32,7 @@ class DocumentServiceTest {
|
|||||||
@Mock FileService fileService;
|
@Mock FileService fileService;
|
||||||
@Mock TagService tagService;
|
@Mock TagService tagService;
|
||||||
@Mock DocumentVersionService documentVersionService;
|
@Mock DocumentVersionService documentVersionService;
|
||||||
|
@Mock AnnotationService annotationService;
|
||||||
@InjectMocks DocumentService documentService;
|
@InjectMocks DocumentService documentService;
|
||||||
|
|
||||||
// ─── getDocumentById ──────────────────────────────────────────────────────
|
// ─── getDocumentById ──────────────────────────────────────────────────────
|
||||||
@@ -135,6 +137,48 @@ class DocumentServiceTest {
|
|||||||
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
assertThat(documentService.getDocumentsByReceiver(receiverId)).containsExactly(doc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── file hash propagation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createDocument_setsFileHashFromUpload_whenFileProvided() throws Exception {
|
||||||
|
DocumentUpdateDTO dto = new DocumentUpdateDTO();
|
||||||
|
dto.setTitle("Doc");
|
||||||
|
org.springframework.mock.web.MockMultipartFile file =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "scan.pdf", "application/pdf", new byte[]{1});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_scan.pdf", "deadbeef");
|
||||||
|
|
||||||
|
Document savedDoc = Document.builder().id(UUID.randomUUID()).title("Doc")
|
||||||
|
.originalFilename("scan.pdf").status(DocumentStatus.PLACEHOLDER).build();
|
||||||
|
when(documentRepository.save(any())).thenReturn(savedDoc);
|
||||||
|
when(documentRepository.findById(any())).thenReturn(Optional.of(savedDoc));
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
|
||||||
|
documentService.createDocument(dto, file);
|
||||||
|
|
||||||
|
org.mockito.ArgumentCaptor<Document> captor = org.mockito.ArgumentCaptor.forClass(Document.class);
|
||||||
|
verify(documentRepository, atLeastOnce()).save(captor.capture());
|
||||||
|
assertThat(captor.getAllValues()).anySatisfy(d -> assertThat(d.getFileHash()).isEqualTo("deadbeef"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void updateDocument_setsFileHashFromUpload_whenNewFileProvided() throws Exception {
|
||||||
|
UUID id = UUID.randomUUID();
|
||||||
|
Document existing = Document.builder()
|
||||||
|
.id(id).title("Alt").originalFilename("old.pdf")
|
||||||
|
.status(DocumentStatus.UPLOADED).build();
|
||||||
|
org.springframework.mock.web.MockMultipartFile newFile =
|
||||||
|
new org.springframework.mock.web.MockMultipartFile("file", "new.pdf", "application/pdf", new byte[]{2});
|
||||||
|
FileService.UploadResult uploadResult = new FileService.UploadResult("documents/uuid_new.pdf", "cafebabe");
|
||||||
|
|
||||||
|
when(documentRepository.findById(id)).thenReturn(Optional.of(existing));
|
||||||
|
when(fileService.uploadFile(any(), any())).thenReturn(uploadResult);
|
||||||
|
when(documentRepository.save(any())).thenReturn(existing);
|
||||||
|
|
||||||
|
documentService.updateDocument(id, new DocumentUpdateDTO(), newFile);
|
||||||
|
|
||||||
|
assertThat(existing.getFileHash()).isEqualTo("cafebabe");
|
||||||
|
}
|
||||||
|
|
||||||
// ─── versioning ───────────────────────────────────────────────────────────
|
// ─── versioning ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -167,4 +211,59 @@ class DocumentServiceTest {
|
|||||||
|
|
||||||
verify(documentVersionService).recordVersion(any(Document.class));
|
verify(documentVersionService).recordVersion(any(Document.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── backfillFileHashes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_skipsDocumentsWithNoFilePath() throws Exception {
|
||||||
|
Document noFile = Document.builder().id(UUID.randomUUID()).build();
|
||||||
|
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of());
|
||||||
|
|
||||||
|
int count = documentService.backfillFileHashes();
|
||||||
|
|
||||||
|
assertThat(count).isZero();
|
||||||
|
verify(fileService, never()).downloadFileBytes(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_computesHashAndSavesDocument() throws Exception {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
||||||
|
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
||||||
|
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
documentService.backfillFileHashes();
|
||||||
|
|
||||||
|
assertThat(doc.getFileHash()).isNotNull().hasSize(64);
|
||||||
|
verify(documentRepository).save(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_propagatesHashToAnnotations() throws Exception {
|
||||||
|
UUID docId = UUID.randomUUID();
|
||||||
|
Document doc = Document.builder().id(docId).filePath("documents/scan.pdf").build();
|
||||||
|
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc));
|
||||||
|
when(fileService.downloadFileBytes("documents/scan.pdf")).thenReturn(new byte[]{1, 2, 3});
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
documentService.backfillFileHashes();
|
||||||
|
|
||||||
|
verify(annotationService).backfillAnnotationFileHashForDocument(eq(docId), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfillFileHashes_returnsCountOfUpdatedDocuments() throws Exception {
|
||||||
|
UUID id1 = UUID.randomUUID();
|
||||||
|
UUID id2 = UUID.randomUUID();
|
||||||
|
Document doc1 = Document.builder().id(id1).filePath("documents/a.pdf").build();
|
||||||
|
Document doc2 = Document.builder().id(id2).filePath("documents/b.pdf").build();
|
||||||
|
when(documentRepository.findByFileHashIsNullAndFilePathIsNotNull()).thenReturn(List.of(doc1, doc2));
|
||||||
|
when(fileService.downloadFileBytes(any())).thenReturn(new byte[]{1});
|
||||||
|
when(documentRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
int count = documentService.backfillFileHashes();
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -304,6 +304,76 @@ class DocumentVersionServiceTest {
|
|||||||
.isInstanceOf(DomainException.class);
|
.isInstanceOf(DomainException.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── backfillMissingVersions ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_createsVersion_withEditorNameDatenimport() {
|
||||||
|
Document doc = minimalDocument();
|
||||||
|
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().getEditorName()).isEqualTo("Datenimport");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_usesDocumentCreatedAt_asSavedAt() {
|
||||||
|
LocalDateTime createdAt = LocalDateTime.of(2020, 3, 15, 10, 0);
|
||||||
|
Document doc = Document.builder()
|
||||||
|
.id(UUID.randomUUID()).title("T").createdAt(createdAt).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()).isEqualTo(createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_setsChangedFieldsEmpty() {
|
||||||
|
Document doc = minimalDocument();
|
||||||
|
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().getChangedFields()).isEqualTo("[]");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_skipsDocuments_thatAlreadyHaveVersions() {
|
||||||
|
Document doc = minimalDocument();
|
||||||
|
DocumentVersion existing = DocumentVersion.builder()
|
||||||
|
.id(UUID.randomUUID()).documentId(doc.getId()).snapshot("{}")
|
||||||
|
.changedFields("[]").editorName("user").savedAt(LocalDateTime.now()).build();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(doc.getId())).thenReturn(List.of(existing));
|
||||||
|
|
||||||
|
int count = versionService.backfillMissingVersions(List.of(doc));
|
||||||
|
|
||||||
|
verify(versionRepository, never()).save(any());
|
||||||
|
assertThat(count).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void backfill_returnsCountOfCreatedVersions() {
|
||||||
|
Document d1 = minimalDocument();
|
||||||
|
Document d2 = minimalDocument();
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d1.getId())).thenReturn(List.of());
|
||||||
|
when(versionRepository.findByDocumentIdOrderBySavedAtAsc(d2.getId())).thenReturn(List.of());
|
||||||
|
when(versionRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
int count = versionService.backfillMissingVersions(List.of(d1, d2));
|
||||||
|
|
||||||
|
assertThat(count).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private void authenticateAs(String username) {
|
private void authenticateAs(String username) {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
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.IOException;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
class FileServiceTest {
|
||||||
|
|
||||||
|
private S3Client s3Client;
|
||||||
|
private FileService fileService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
s3Client = mock(S3Client.class);
|
||||||
|
fileService = new FileService(s3Client, "test-bucket");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadFile_returnsS3Key() throws IOException {
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "test.pdf", "application/pdf", new byte[]{1, 2, 3});
|
||||||
|
|
||||||
|
FileService.UploadResult result = fileService.uploadFile(file, "test.pdf");
|
||||||
|
|
||||||
|
assertThat(result.s3Key()).startsWith("documents/");
|
||||||
|
assertThat(result.s3Key()).endsWith("_test.pdf");
|
||||||
|
verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadFile_returnsCorrectSha256FileHash() throws IOException, NoSuchAlgorithmException {
|
||||||
|
byte[] content = "hello pdf content".getBytes();
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "doc.pdf", "application/pdf", content);
|
||||||
|
|
||||||
|
FileService.UploadResult result = fileService.uploadFile(file, "doc.pdf");
|
||||||
|
|
||||||
|
// Compute expected hash independently
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hashBytes = digest.digest(content);
|
||||||
|
StringBuilder expected = new StringBuilder();
|
||||||
|
for (byte b : hashBytes) {
|
||||||
|
expected.append(String.format("%02x", b));
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThat(result.fileHash()).isEqualTo(expected.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadFile_differentContents_produceDifferentHashes() throws IOException {
|
||||||
|
MockMultipartFile file1 = new MockMultipartFile(
|
||||||
|
"f", "a.pdf", "application/pdf", new byte[]{1, 2, 3});
|
||||||
|
MockMultipartFile file2 = new MockMultipartFile(
|
||||||
|
"f", "b.pdf", "application/pdf", new byte[]{4, 5, 6});
|
||||||
|
|
||||||
|
FileService.UploadResult r1 = fileService.uploadFile(file1, "a.pdf");
|
||||||
|
FileService.UploadResult r2 = fileService.uploadFile(file2, "b.pdf");
|
||||||
|
|
||||||
|
assertThat(r1.fileHash()).isNotEqualTo(r2.fileHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadFile_sameContents_produceSameHash() throws IOException {
|
||||||
|
byte[] content = new byte[]{10, 20, 30};
|
||||||
|
MockMultipartFile file1 = new MockMultipartFile("f", "x.pdf", "application/pdf", content);
|
||||||
|
MockMultipartFile file2 = new MockMultipartFile("f", "y.pdf", "application/pdf", content);
|
||||||
|
|
||||||
|
FileService.UploadResult r1 = fileService.uploadFile(file1, "x.pdf");
|
||||||
|
FileService.UploadResult r2 = fileService.uploadFile(file2, "y.pdf");
|
||||||
|
|
||||||
|
assertThat(r1.fileHash()).isEqualTo(r2.fileHash());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,7 @@ services:
|
|||||||
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
|
||||||
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
|
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
|
||||||
S3_REGION: us-east-1
|
S3_REGION: us-east-1
|
||||||
|
SPRING_PROFILES_ACTIVE: dev,e2e
|
||||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
|
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
|
||||||
# Defaults to the local Mailpit catcher — override in .env for production SMTP
|
# Defaults to the local Mailpit catcher — override in .env for production SMTP
|
||||||
MAIL_HOST: ${MAIL_HOST:-mailpit}
|
MAIL_HOST: ${MAIL_HOST:-mailpit}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"value": "de",
|
"value": "de",
|
||||||
"domain": "localhost",
|
"domain": "localhost",
|
||||||
"path": "/",
|
"path": "/",
|
||||||
"expires": 1808565334.192108,
|
"expires": 1808896929.897686,
|
||||||
"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": 1774091734.449243,
|
"expires": 1774423330.233039,
|
||||||
"httpOnly": true,
|
"httpOnly": true,
|
||||||
"secure": false,
|
"secure": false,
|
||||||
"sameSite": "Strict"
|
"sameSite": "Strict"
|
||||||
|
|||||||
@@ -216,3 +216,35 @@ test.describe('Admin — tag management', () => {
|
|||||||
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
|
await page.screenshot({ path: 'test-results/e2e/admin-tag-restored.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── System tab — backfill file hashes ────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('Admin system tab — backfill file hashes', () => {
|
||||||
|
test('admin triggers file hash backfill and sees success message', async ({ request, page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
|
||||||
|
// Create a document via API so there is at least one without a hash
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Backfill Hash Test' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||||
|
|
||||||
|
await page.goto('/admin');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Navigate to System tab
|
||||||
|
await page.getByRole('button', { name: /system/i }).click();
|
||||||
|
|
||||||
|
// Click the backfill hashes button
|
||||||
|
const btn = page.getByRole('button', { name: /datei-hashes berechnen/i });
|
||||||
|
await expect(btn).toBeVisible();
|
||||||
|
await btn.click();
|
||||||
|
|
||||||
|
// Success message must appear (count >= 0)
|
||||||
|
await expect(page.locator('text=/\\d+ Dokumente wurden aktualisiert/i')).toBeVisible({
|
||||||
|
timeout: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/admin-backfill-hashes.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document management E2E tests.
|
* Document management E2E tests.
|
||||||
@@ -142,3 +147,358 @@ test.describe('Document edit', () => {
|
|||||||
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
|
await page.screenshot({ path: 'test-results/e2e/document-edit-date-error.png' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── PDF Viewer ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
|
||||||
|
|
||||||
|
test.describe('PDF viewer', () => {
|
||||||
|
let pdfDocHref: string;
|
||||||
|
let noFileDocHref: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
// Create a document with a PDF file.
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E PDF Viewer Test' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
|
pdfDocHref = `${baseURL}/documents/${doc.id}`;
|
||||||
|
|
||||||
|
// Create a document WITHOUT a file — used to verify no canvas is rendered.
|
||||||
|
const noFileRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E No-File Test' }
|
||||||
|
});
|
||||||
|
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
|
||||||
|
const noFileDoc = await noFileRes.json();
|
||||||
|
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// There must be NO iframe — we replaced it with PDF.js canvas rendering.
|
||||||
|
await expect(page.locator('iframe')).not.toBeAttached();
|
||||||
|
|
||||||
|
// At least one canvas element must be visible (one per rendered page).
|
||||||
|
await expect(page.locator('canvas').first()).toBeVisible({ timeout: 15000 });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-canvas.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('page navigation controls are visible', async ({ page }) => {
|
||||||
|
await page.goto(pdfDocHref);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 15000 });
|
||||||
|
|
||||||
|
await expect(page.getByRole('button', { name: /prev|previous|zurück|vorige/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /next|weiter|nächste/i })).toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('document without a file has no canvas', async ({ page }) => {
|
||||||
|
// A document with no file attached must not render a PDF canvas.
|
||||||
|
await page.goto(noFileDocHref);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// No canvas — this document has no file
|
||||||
|
await expect(page.locator('canvas')).not.toBeAttached();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── PDF Annotations (admin) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Shared with the read-only user describe block below
|
||||||
|
let sharedAnnotationDocId: string;
|
||||||
|
|
||||||
|
test.describe('PDF annotations — admin', () => {
|
||||||
|
let annotationDocHref: string;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
// Create a document with a PDF via API — much faster than UI automation.
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Annotations Test' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
annotationDocHref = `${baseURL}/documents/${doc.id}`;
|
||||||
|
sharedAnnotationDocId = doc.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin user sees an active Annotieren button on a PDF', async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.goto(annotationDocHref);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
|
// Admin has ANNOTATE_ALL — button must be enabled
|
||||||
|
const annotateBtn = page.getByRole('button', { name: /^annotieren$/i });
|
||||||
|
await expect(annotateBtn).toBeVisible();
|
||||||
|
await expect(annotateBtn).not.toBeDisabled();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/annotations-button-admin.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can draw an annotation and it appears on the page', async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.goto(annotationDocHref);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
|
// Enable annotate mode
|
||||||
|
await page.getByRole('button', { name: /^annotieren$/i }).click();
|
||||||
|
|
||||||
|
// Color picker must appear
|
||||||
|
await expect(page.getByLabel(/farbe/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Draw on the annotation layer overlay
|
||||||
|
const annotationLayer = page.locator('[role="presentation"]').last();
|
||||||
|
const box = await annotationLayer.boundingBox();
|
||||||
|
if (!box) throw new Error('Annotation layer not found');
|
||||||
|
|
||||||
|
const startX = box.x + box.width * 0.3;
|
||||||
|
const startY = box.y + box.height * 0.3;
|
||||||
|
const endX = box.x + box.width * 0.55;
|
||||||
|
const endY = box.y + box.height * 0.55;
|
||||||
|
|
||||||
|
await page.mouse.move(startX, startY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(endX, endY);
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/annotation-drawn.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('annotation persists after page reload', async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.goto(annotationDocHref);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
|
// Annotation from the previous test must be loaded from the API
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/annotation-persisted.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin can delete an annotation', async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.goto(annotationDocHref);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
|
// Ensure annotation is visible before enabling annotate mode
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enable annotate mode to show delete buttons
|
||||||
|
await page.getByRole('button', { name: /^annotieren$/i }).click();
|
||||||
|
|
||||||
|
const deleteBtn = page.getByRole('button', { name: /annotation löschen/i }).first();
|
||||||
|
await expect(deleteBtn).toBeVisible({ timeout: 8000 });
|
||||||
|
await deleteBtn.click();
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, {
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/annotation-deleted.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── PDF Annotations — file hash (version awareness) ─────────────────────────
|
||||||
|
|
||||||
|
test.describe('PDF annotations — file hash versioning', () => {
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
const PDF_FIXTURE2 = path.resolve(__dirname, 'fixtures/minimal2.pdf');
|
||||||
|
|
||||||
|
test('annotations are hidden after a different file is uploaded', async ({ page, request }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
// 1. Create document and upload original PDF
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Hash Test — version' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
|
||||||
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
|
// 2. Create an annotation via API
|
||||||
|
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
||||||
|
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#ff0000' }
|
||||||
|
});
|
||||||
|
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
||||||
|
|
||||||
|
// 3. Verify annotation appears before re-upload
|
||||||
|
await page.goto(`${baseURL}/documents/${doc.id}`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Upload a different file (different hash)
|
||||||
|
const reuploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal2.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!reuploadRes.ok()) throw new Error(`Re-upload failed: ${reuploadRes.status()}`);
|
||||||
|
|
||||||
|
// 5. Reload — annotation must be hidden and notice shown
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, { timeout: 8000 });
|
||||||
|
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).toBeVisible({
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/annotation-hidden-after-reupload.png' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('annotations reappear after re-uploading the original file', async ({ page, request }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
|
||||||
|
// 1. Create document and upload original PDF
|
||||||
|
const createRes = await request.post('/api/documents', {
|
||||||
|
multipart: { title: 'E2E Hash Test — restore' }
|
||||||
|
});
|
||||||
|
if (!createRes.ok()) throw new Error(`Create failed: ${createRes.status()}`);
|
||||||
|
const doc = await createRes.json();
|
||||||
|
|
||||||
|
const originalBytes = fs.readFileSync(PDF_FIXTURE);
|
||||||
|
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!uploadRes.ok()) throw new Error(`Upload failed: ${uploadRes.status()}`);
|
||||||
|
|
||||||
|
// 2. Create annotation
|
||||||
|
const annotRes = await request.post(`/api/documents/${doc.id}/annotations`, {
|
||||||
|
data: { pageNumber: 1, x: 0.1, y: 0.1, width: 0.2, height: 0.2, color: '#0000ff' }
|
||||||
|
});
|
||||||
|
if (!annotRes.ok()) throw new Error(`Create annotation failed: ${annotRes.status()}`);
|
||||||
|
|
||||||
|
// 3. Replace with different file
|
||||||
|
const replaceRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: {
|
||||||
|
name: 'minimal2.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
buffer: fs.readFileSync(PDF_FIXTURE2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!replaceRes.ok()) throw new Error(`Replace failed: ${replaceRes.status()}`);
|
||||||
|
|
||||||
|
// 4. Re-upload original file (restoring the hash)
|
||||||
|
const restoreRes = await request.put(`/api/documents/${doc.id}`, {
|
||||||
|
multipart: {
|
||||||
|
title: doc.title,
|
||||||
|
file: { name: 'minimal.pdf', mimeType: 'application/pdf', buffer: originalBytes }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!restoreRes.ok()) throw new Error(`Restore failed: ${restoreRes.status()}`);
|
||||||
|
|
||||||
|
// 5. Verify annotation reappears and notice is gone
|
||||||
|
await page.goto(`${baseURL}/documents/${doc.id}`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
|
||||||
|
|
||||||
|
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
|
||||||
|
timeout: 8000
|
||||||
|
});
|
||||||
|
await expect(page.locator('[data-testid="annotation-outdated-notice"]')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/annotation-restored.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
test.describe('PDF annotations — read-only user', () => {
|
||||||
|
// Isolated session — does not share the admin storage state
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test('read-only user does not see the Annotieren button', async ({ page }) => {
|
||||||
|
test.setTimeout(60_000);
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel('Benutzername').fill('reader');
|
||||||
|
await page.getByLabel('Passwort').fill('reader123');
|
||||||
|
await page.getByRole('button', { name: 'Anmelden' }).click();
|
||||||
|
await page.waitForURL('/');
|
||||||
|
|
||||||
|
// Navigate directly to the PDF document created by the admin beforeAll.
|
||||||
|
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
|
||||||
|
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
|
// Reader users do not have ANNOTATE_ALL permission — the button must not be shown at all.
|
||||||
|
const annotateBtn = page.getByRole('button', { name: /annotieren/i });
|
||||||
|
await expect(annotateBtn).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
21
frontend/e2e/fixtures/minimal.pdf
Normal file
21
frontend/e2e/fixtures/minimal.pdf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
1 0 obj
|
||||||
|
<</Type/Catalog/Pages 2 0 R>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<</Type/Pages/Kids[3 0 R]/Count 1>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R>>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000054 00000 n
|
||||||
|
0000000105 00000 n
|
||||||
|
trailer
|
||||||
|
<</Size 4/Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
170
|
||||||
|
%%EOF
|
||||||
21
frontend/e2e/fixtures/minimal2.pdf
Normal file
21
frontend/e2e/fixtures/minimal2.pdf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
1 0 obj
|
||||||
|
<</Type/Catalog/Pages 2 0 R>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<</Type/Pages/Kids[3 0 R]/Count 1>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<</Type/Page/MediaBox[0 0 3 3]/Parent 2 0 R>>
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 4
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
trailer
|
||||||
|
<</Size 4/Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
190
|
||||||
|
%%EOF
|
||||||
@@ -1,50 +1,72 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Document edit history E2E tests.
|
* Document edit history E2E tests.
|
||||||
* Relies on the 'Document creation' and 'Document editing' tests in documents.spec.ts
|
* Creates its own test document (two versions) in beforeAll so these tests
|
||||||
* having run first (they create and edit "E2E Testbrief (überarbeitet)").
|
* are fully independent of any other spec file.
|
||||||
* Assumes auth setup has run.
|
|
||||||
*/
|
*/
|
||||||
test.describe('Document history panel', () => {
|
|
||||||
test('history section appears after creating and editing a document', async ({ page }) => {
|
|
||||||
// Find the document edited in the documents.spec.ts editing test
|
|
||||||
await page.goto('/?q=E2E+Testbrief');
|
|
||||||
await page.waitForSelector('[data-hydrated]');
|
|
||||||
const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first();
|
|
||||||
const href = await docLink.getAttribute('href');
|
|
||||||
await page.goto(href!);
|
|
||||||
|
|
||||||
// History section should be present (collapsed by default)
|
let docPath: string;
|
||||||
|
|
||||||
|
test.describe('Document history panel', () => {
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
// Create a fresh browser context that uses the stored auth session
|
||||||
|
const context = await browser.newContext({
|
||||||
|
storageState: path.join(__dirname, '.auth/user.json'),
|
||||||
|
locale: 'de-DE'
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// 1. Create a new document
|
||||||
|
await page.goto('/documents/new');
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.getByLabel('Titel').fill('E2E History Test Dokument');
|
||||||
|
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||||
|
// Wait for redirect to the new document's UUID-based URL (not /documents/new)
|
||||||
|
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||||
|
docPath = new URL(page.url()).pathname;
|
||||||
|
|
||||||
|
// 2. Edit the document to create a second version
|
||||||
|
await page.goto(`${docPath}/edit`);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
await page.getByLabel('Titel').fill('E2E History Test Dokument (bearbeitet)');
|
||||||
|
await page.getByRole('button', { name: /Speichern/i }).click();
|
||||||
|
await page.waitForURL(/\/documents\/[0-9a-f-]{36}$/);
|
||||||
|
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history section appears and shows two versions', async ({ page }) => {
|
||||||
|
await page.goto(docPath);
|
||||||
|
await page.waitForSelector('[data-hydrated]');
|
||||||
|
|
||||||
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
||||||
await expect(historyToggle).toBeVisible();
|
await expect(historyToggle).toBeVisible();
|
||||||
|
|
||||||
// Expand the history section
|
|
||||||
await historyToggle.click();
|
await historyToggle.click();
|
||||||
|
|
||||||
// Should show at least two version entries (created + edited)
|
// Wait for versions to load (API call happens after panel opens)
|
||||||
const versionItems = page.locator('[data-testid="history-version"]');
|
const versionItems = page.locator('[data-testid="history-version"]');
|
||||||
await expect(versionItems).toHaveCount(2, { timeout: 5000 });
|
await expect(versionItems).toHaveCount(2, { timeout: 10000 });
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/e2e/history-versions-list.png' });
|
await page.screenshot({ path: 'test-results/e2e/history-versions-list.png' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('diff view highlights changed field after title edit', async ({ page }) => {
|
test('diff view highlights changed field after title edit', async ({ page }) => {
|
||||||
await page.goto('/?q=E2E+Testbrief');
|
await page.goto(docPath);
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first();
|
|
||||||
const href = await docLink.getAttribute('href');
|
|
||||||
await page.goto(href!);
|
|
||||||
|
|
||||||
// Expand history
|
|
||||||
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
||||||
await historyToggle.click();
|
await historyToggle.click();
|
||||||
|
|
||||||
// Click the second version (the edit) to see its diff
|
// Wait for versions to load, then click the second one (the edit)
|
||||||
const versionItems = page.locator('[data-testid="history-version"]');
|
const versionItems = page.locator('[data-testid="history-version"]');
|
||||||
|
await expect(versionItems.nth(1)).toBeVisible({ timeout: 10000 });
|
||||||
await versionItems.nth(1).click();
|
await versionItems.nth(1).click();
|
||||||
|
|
||||||
// The diff panel should show the "Titel" field as changed
|
|
||||||
const diffPanel = page.locator('[data-testid="history-diff"]');
|
const diffPanel = page.locator('[data-testid="history-diff"]');
|
||||||
await expect(diffPanel).toBeVisible();
|
await expect(diffPanel).toBeVisible();
|
||||||
await expect(diffPanel.getByText(/Titel/i)).toBeVisible();
|
await expect(diffPanel.getByText(/Titel/i)).toBeVisible();
|
||||||
@@ -53,34 +75,35 @@ test.describe('Document history panel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('compare mode lets user compare any two versions', async ({ page }) => {
|
test('compare mode lets user compare any two versions', async ({ page }) => {
|
||||||
await page.goto('/?q=E2E+Testbrief');
|
await page.goto(docPath);
|
||||||
await page.waitForSelector('[data-hydrated]');
|
await page.waitForSelector('[data-hydrated]');
|
||||||
const docLink = page.getByRole('link', { name: /E2E Testbrief/ }).first();
|
|
||||||
const href = await docLink.getAttribute('href');
|
|
||||||
await page.goto(href!);
|
|
||||||
|
|
||||||
// Expand history
|
|
||||||
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
const historyToggle = page.getByRole('button', { name: /Verlauf/i });
|
||||||
await historyToggle.click();
|
await historyToggle.click();
|
||||||
|
|
||||||
// Switch to compare mode
|
// Wait for versions to load before the compare button appears
|
||||||
|
await expect(page.locator('[data-testid="history-version"]').first()).toBeVisible({
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
const compareBtn = page.getByRole('button', { name: /Vergleichen/i });
|
const compareBtn = page.getByRole('button', { name: /Vergleichen/i });
|
||||||
await expect(compareBtn).toBeVisible();
|
await expect(compareBtn).toBeVisible();
|
||||||
await compareBtn.click();
|
await compareBtn.click();
|
||||||
|
|
||||||
// Two version selects should appear
|
|
||||||
const selectA = page.getByLabel(/Version A/i);
|
const selectA = page.getByLabel(/Version A/i);
|
||||||
const selectB = page.getByLabel(/Version B/i);
|
const selectB = page.getByLabel(/Version B/i);
|
||||||
await expect(selectA).toBeVisible();
|
await expect(selectA).toBeVisible();
|
||||||
await expect(selectB).toBeVisible();
|
await expect(selectB).toBeVisible();
|
||||||
|
|
||||||
// Apply the comparison
|
// Select version 1 for A and version 2 for B
|
||||||
|
await selectA.selectOption({ index: 1 });
|
||||||
|
await selectB.selectOption({ index: 2 });
|
||||||
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: /Vergleichen/i })
|
.getByRole('button', { name: /Vergleichen/i })
|
||||||
.last()
|
.last()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
// Diff panel should be visible
|
|
||||||
const diffPanel = page.locator('[data-testid="history-diff"]');
|
const diffPanel = page.locator('[data-testid="history-diff"]');
|
||||||
await expect(diffPanel).toBeVisible();
|
await expect(diffPanel).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
|
||||||
|
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
|
||||||
|
"annotation_outdated_notice": "Einige Annotationen beziehen sich auf eine frühere Dateiversion und werden nicht angezeigt.",
|
||||||
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
|
||||||
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
|
||||||
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",
|
||||||
@@ -233,5 +236,24 @@
|
|||||||
"history_field_summary": "Zusammenfassung",
|
"history_field_summary": "Zusammenfassung",
|
||||||
"history_field_sender": "Absender",
|
"history_field_sender": "Absender",
|
||||||
"history_field_receivers": "Empfänger",
|
"history_field_receivers": "Empfänger",
|
||||||
"history_field_tags": "Schlagworte"
|
"history_field_tags": "Schlagworte",
|
||||||
|
"admin_tab_system": "System",
|
||||||
|
"admin_system_backfill_heading": "Verlaufsdaten auffüllen",
|
||||||
|
"admin_system_backfill_description": "Erstellt einen initialen Verlaufseintrag für alle Dokumente, die noch keinen Verlauf haben (z.B. importierte Dokumente). Dadurch werden beim nächsten Bearbeiten nur die tatsächlich geänderten Felder hervorgehoben.",
|
||||||
|
"admin_system_backfill_btn": "Jetzt auffüllen",
|
||||||
|
"admin_system_backfill_success": "{count} Dokumente wurden aufgefüllt.",
|
||||||
|
"admin_system_backfill_hashes_heading": "Datei-Hashes berechnen",
|
||||||
|
"admin_system_backfill_hashes_description": "Berechnet den SHA-256-Hash für alle bereits hochgeladenen Dokumente, die noch keinen Hash haben. Dadurch werden Annotationen korrekt mit ihrer Dateiversion verknüpft und wieder angezeigt.",
|
||||||
|
"admin_system_backfill_hashes_btn": "Datei-Hashes berechnen",
|
||||||
|
"admin_system_backfill_hashes_success": "{count} Dokumente wurden aktualisiert.",
|
||||||
|
"comp_expandable_show_more": "Mehr anzeigen",
|
||||||
|
"comp_expandable_show_less": "Weniger anzeigen",
|
||||||
|
"error_comment_not_found": "Der Kommentar wurde nicht gefunden.",
|
||||||
|
"comment_section_title": "Diskussion",
|
||||||
|
"comment_placeholder": "Kommentar schreiben…",
|
||||||
|
"comment_btn_post": "Senden",
|
||||||
|
"comment_btn_reply": "Antworten",
|
||||||
|
"comment_edited_label": "· bearbeitet",
|
||||||
|
"comment_panel_title": "Kommentare",
|
||||||
|
"comment_panel_close": "Schließen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"error_annotation_not_found": "Annotation not found.",
|
||||||
|
"error_annotation_overlap": "The annotation overlaps an existing one.",
|
||||||
|
"annotation_outdated_notice": "Some annotations refer to an earlier file version and are not shown.",
|
||||||
"error_document_not_found": "Document not found.",
|
"error_document_not_found": "Document not found.",
|
||||||
"error_document_no_file": "No file is associated with this document.",
|
"error_document_no_file": "No file is associated with this document.",
|
||||||
"error_file_not_found": "The file could not be found in storage.",
|
"error_file_not_found": "The file could not be found in storage.",
|
||||||
@@ -233,5 +236,24 @@
|
|||||||
"history_field_summary": "Summary",
|
"history_field_summary": "Summary",
|
||||||
"history_field_sender": "Sender",
|
"history_field_sender": "Sender",
|
||||||
"history_field_receivers": "Receivers",
|
"history_field_receivers": "Receivers",
|
||||||
"history_field_tags": "Tags"
|
"history_field_tags": "Tags",
|
||||||
|
"admin_tab_system": "System",
|
||||||
|
"admin_system_backfill_heading": "Backfill history data",
|
||||||
|
"admin_system_backfill_description": "Creates an initial history entry for all documents that do not have one yet (e.g. imported documents). This ensures that future edits only highlight actually changed fields.",
|
||||||
|
"admin_system_backfill_btn": "Backfill now",
|
||||||
|
"admin_system_backfill_success": "{count} documents were backfilled.",
|
||||||
|
"admin_system_backfill_hashes_heading": "Compute file hashes",
|
||||||
|
"admin_system_backfill_hashes_description": "Computes the SHA-256 hash for all previously uploaded documents that do not have one yet. This ensures annotations are correctly linked to their file version and shown again.",
|
||||||
|
"admin_system_backfill_hashes_btn": "Compute file hashes",
|
||||||
|
"admin_system_backfill_hashes_success": "{count} documents were updated.",
|
||||||
|
"comp_expandable_show_more": "Show more",
|
||||||
|
"comp_expandable_show_less": "Show less",
|
||||||
|
"error_comment_not_found": "The comment could not be found.",
|
||||||
|
"comment_section_title": "Discussion",
|
||||||
|
"comment_placeholder": "Write a comment…",
|
||||||
|
"comment_btn_post": "Send",
|
||||||
|
"comment_btn_reply": "Reply",
|
||||||
|
"comment_edited_label": "· edited",
|
||||||
|
"comment_panel_title": "Comments",
|
||||||
|
"comment_panel_close": "Close"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"error_annotation_not_found": "Anotación no encontrada.",
|
||||||
|
"error_annotation_overlap": "La anotación se superpone con una existente.",
|
||||||
|
"annotation_outdated_notice": "Algunas anotaciones hacen referencia a una versión anterior del archivo y no se muestran.",
|
||||||
"error_document_not_found": "Documento no encontrado.",
|
"error_document_not_found": "Documento no encontrado.",
|
||||||
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
|
||||||
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",
|
||||||
@@ -233,5 +236,24 @@
|
|||||||
"history_field_summary": "Resumen",
|
"history_field_summary": "Resumen",
|
||||||
"history_field_sender": "Remitente",
|
"history_field_sender": "Remitente",
|
||||||
"history_field_receivers": "Destinatarios",
|
"history_field_receivers": "Destinatarios",
|
||||||
"history_field_tags": "Etiquetas"
|
"history_field_tags": "Etiquetas",
|
||||||
|
"admin_tab_system": "Sistema",
|
||||||
|
"admin_system_backfill_heading": "Completar datos de historial",
|
||||||
|
"admin_system_backfill_description": "Crea una entrada de historial inicial para todos los documentos que aún no tienen ninguna (p.ej. documentos importados). Así, en la próxima edición solo se resaltarán los campos realmente modificados.",
|
||||||
|
"admin_system_backfill_btn": "Completar ahora",
|
||||||
|
"admin_system_backfill_success": "{count} documentos fueron completados.",
|
||||||
|
"admin_system_backfill_hashes_heading": "Calcular hashes de archivo",
|
||||||
|
"admin_system_backfill_hashes_description": "Calcula el hash SHA-256 para todos los documentos ya subidos que aún no tienen uno. Así las anotaciones se vinculan correctamente a su versión del archivo y vuelven a mostrarse.",
|
||||||
|
"admin_system_backfill_hashes_btn": "Calcular hashes de archivo",
|
||||||
|
"admin_system_backfill_hashes_success": "{count} documentos fueron actualizados.",
|
||||||
|
"comp_expandable_show_more": "Mostrar más",
|
||||||
|
"comp_expandable_show_less": "Mostrar menos",
|
||||||
|
"error_comment_not_found": "El comentario no pudo encontrarse.",
|
||||||
|
"comment_section_title": "Discusión",
|
||||||
|
"comment_placeholder": "Escribe un comentario…",
|
||||||
|
"comment_btn_post": "Enviar",
|
||||||
|
"comment_btn_reply": "Responder",
|
||||||
|
"comment_edited_label": "· editado",
|
||||||
|
"comment_panel_title": "Comentarios",
|
||||||
|
"comment_panel_close": "Cerrar"
|
||||||
}
|
}
|
||||||
|
|||||||
273
frontend/package-lock.json
generated
273
frontend/package-lock.json
generated
@@ -9,7 +9,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5"
|
"openapi-fetch": "^0.13.5",
|
||||||
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
@@ -885,6 +886,256 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@napi-rs/canvas": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"workspaces": [
|
||||||
|
"e2e/*"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas-android-arm64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-darwin-arm64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-darwin-x64": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu": "0.1.97",
|
||||||
|
"@napi-rs/canvas-linux-x64-musl": "0.1.97",
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc": "0.1.97",
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc": "0.1.97"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-arm64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-darwin-x64": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-linux-x64-musl": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
|
||||||
|
"version": "0.1.97",
|
||||||
|
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz",
|
||||||
|
"integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.58.2",
|
"version": "1.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
@@ -3954,6 +4205,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-readable-to-web-readable-stream": {
|
||||||
|
"version": "0.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
|
||||||
|
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/obug": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
@@ -4129,6 +4387,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pdfjs-dist": {
|
||||||
|
"version": "5.5.207",
|
||||||
|
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz",
|
||||||
|
"integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0 || >=22.13.0 || >=24"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@napi-rs/canvas": "^0.1.95",
|
||||||
|
"node-readable-to-web-readable-stream": "^0.4.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5"
|
"openapi-fetch": "^0.13.5",
|
||||||
|
"pdfjs-dist": "^5.5.207"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.4.0",
|
"@eslint/compat": "^1.4.0",
|
||||||
|
|||||||
90
frontend/src/lib/components/AnnotationCommentPanel.svelte
Normal file
90
frontend/src/lib/components/AnnotationCommentPanel.svelte
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CommentThread from './CommentThread.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documentId: string;
|
||||||
|
annotationId: string;
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCountChange?: (count: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
documentId,
|
||||||
|
annotationId,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
onClose,
|
||||||
|
onCountChange
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Desktop / tablet panel (≥ sm): absolute overlay on the right side -->
|
||||||
|
<div
|
||||||
|
class="absolute top-0 right-0 z-50 hidden h-full w-80 flex-col border-l border-brand-sand bg-white shadow-2xl sm:flex"
|
||||||
|
>
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
||||||
|
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.comment_panel_title()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label={m.comment_panel_close()}
|
||||||
|
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
<CommentThread
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={annotationId}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
loadOnMount={true}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile modal (< sm): fixed full-screen with slide-up sheet -->
|
||||||
|
<div class="fixed inset-0 z-50 flex flex-col sm:hidden">
|
||||||
|
<!-- Semi-transparent backdrop -->
|
||||||
|
<div class="flex-1 bg-black/40" onclick={onClose} role="presentation"></div>
|
||||||
|
|
||||||
|
<!-- Slide-up panel -->
|
||||||
|
<div class="flex max-h-[80vh] flex-col rounded-t-2xl bg-white shadow-2xl">
|
||||||
|
<div class="flex shrink-0 items-center justify-between border-b border-brand-sand px-4 py-3">
|
||||||
|
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.comment_panel_title()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
aria-label={m.comment_panel_close()}
|
||||||
|
class="rounded p-1 text-gray-400 transition-colors hover:bg-brand-sand/50 hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-y-auto p-4">
|
||||||
|
<CommentThread
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={annotationId}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
loadOnMount={true}
|
||||||
|
onCountChange={onCountChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
218
frontend/src/lib/components/AnnotationLayer.svelte
Normal file
218
frontend/src/lib/components/AnnotationLayer.svelte
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Annotation = {
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
pageNumber: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
color: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DrawRect = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
annotations = [],
|
||||||
|
canAnnotate,
|
||||||
|
color,
|
||||||
|
onDraw,
|
||||||
|
onDelete,
|
||||||
|
commentCounts,
|
||||||
|
onAnnotationClick
|
||||||
|
}: {
|
||||||
|
annotations: Annotation[];
|
||||||
|
canAnnotate: boolean;
|
||||||
|
color: string;
|
||||||
|
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
commentCounts?: Record<string, number>;
|
||||||
|
onAnnotationClick?: (id: string) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let drawStart = $state<{ x: number; y: number } | null>(null);
|
||||||
|
let drawRect = $state<DrawRect | null>(null);
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: number; y: number } {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: (event.clientX - rect.left) / rect.width,
|
||||||
|
y: (event.clientY - rect.top) / rect.height
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerDown(event: PointerEvent) {
|
||||||
|
if (!canAnnotate) return;
|
||||||
|
|
||||||
|
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
|
||||||
|
|
||||||
|
const container = event.currentTarget as HTMLElement;
|
||||||
|
container.setPointerCapture(event.pointerId);
|
||||||
|
|
||||||
|
const coords = getNormalizedCoords(event, container);
|
||||||
|
drawStart = coords;
|
||||||
|
drawRect = { x: coords.x, y: coords.y, width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerMove(event: PointerEvent) {
|
||||||
|
if (!canAnnotate || !drawStart) return;
|
||||||
|
|
||||||
|
const container = event.currentTarget as HTMLElement;
|
||||||
|
const coords = getNormalizedCoords(event, container);
|
||||||
|
|
||||||
|
const x = Math.min(drawStart.x, coords.x);
|
||||||
|
const y = Math.min(drawStart.y, coords.y);
|
||||||
|
const width = Math.abs(coords.x - drawStart.x);
|
||||||
|
const height = Math.abs(coords.y - drawStart.y);
|
||||||
|
|
||||||
|
drawRect = { x, y, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePointerUp(event: PointerEvent) {
|
||||||
|
if (!canAnnotate || !drawStart || !drawRect) return;
|
||||||
|
|
||||||
|
const container = event.currentTarget as HTMLElement;
|
||||||
|
const coords = getNormalizedCoords(event, container);
|
||||||
|
|
||||||
|
const x = Math.min(drawStart.x, coords.x);
|
||||||
|
const y = Math.min(drawStart.y, coords.y);
|
||||||
|
const width = Math.abs(coords.x - drawStart.x);
|
||||||
|
const height = Math.abs(coords.y - drawStart.y);
|
||||||
|
|
||||||
|
if (width > 0.01 && height > 0.01) {
|
||||||
|
onDraw({ x, y, width, height });
|
||||||
|
}
|
||||||
|
|
||||||
|
drawStart = null;
|
||||||
|
drawRect = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hoveredId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const containerStyle = $derived(
|
||||||
|
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={containerStyle}
|
||||||
|
role="presentation"
|
||||||
|
onpointerdown={handlePointerDown}
|
||||||
|
onpointermove={handlePointerMove}
|
||||||
|
onpointerup={handlePointerUp}
|
||||||
|
>
|
||||||
|
{#each annotations as annotation (annotation.id)}
|
||||||
|
<div
|
||||||
|
data-testid="annotation-{annotation.id}"
|
||||||
|
data-annotation
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
aria-label="Kommentare anzeigen"
|
||||||
|
onclick={() => onAnnotationClick?.(annotation.id)}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') onAnnotationClick?.(annotation.id); }}
|
||||||
|
onmouseenter={() => (hoveredId = annotation.id)}
|
||||||
|
onmouseleave={() => (hoveredId = null)}
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
left: {annotation.x * 100}%;
|
||||||
|
top: {annotation.y * 100}%;
|
||||||
|
width: {annotation.width * 100}%;
|
||||||
|
height: {annotation.height * 100}%;
|
||||||
|
background-color: {hexToRgba(annotation.color, hoveredId === annotation.id ? 0.5 : 0.3)};
|
||||||
|
box-shadow: {hoveredId === annotation.id ? `inset 0 0 0 2px ${hexToRgba(annotation.color, 0.8)}` : 'none'};
|
||||||
|
pointer-events: auto;
|
||||||
|
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
{onAnnotationClick && !canAnnotate ? 'cursor: pointer;' : ''}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{#if canAnnotate}
|
||||||
|
<button
|
||||||
|
aria-label="Annotation löschen"
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const count = commentCounts?.[annotation.id] ?? 0;
|
||||||
|
if (count > 0) {
|
||||||
|
const msg =
|
||||||
|
count === 1
|
||||||
|
? 'Diese Annotation hat 1 Kommentar. Beim Löschen wird er ebenfalls entfernt. Fortfahren?'
|
||||||
|
: `Diese Annotation hat ${count} Kommentare. Beim Löschen werden sie ebenfalls entfernt. Fortfahren?`;
|
||||||
|
if (!window.confirm(msg)) return;
|
||||||
|
}
|
||||||
|
onDelete(annotation.id);
|
||||||
|
}}
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
pointer-events: auto;
|
||||||
|
">×</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if (commentCounts?.[annotation.id] ?? 0) > 0}
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
bottom: -10px;
|
||||||
|
right: -10px;
|
||||||
|
background-color: #002850;
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
min-width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
line-height: 18px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{commentCounts?.[annotation.id]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if drawRect && drawRect.width > 0}
|
||||||
|
<div
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
left: {drawRect.x * 100}%;
|
||||||
|
top: {drawRect.y * 100}%;
|
||||||
|
width: {drawRect.width * 100}%;
|
||||||
|
height: {drawRect.height * 100}%;
|
||||||
|
border: 2px dashed {color};
|
||||||
|
opacity: 0.3;
|
||||||
|
pointer-events: none;
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
74
frontend/src/lib/components/AnnotationLayer.svelte.spec.ts
Normal file
74
frontend/src/lib/components/AnnotationLayer.svelte.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
type Annotation = {
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
pageNumber: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
color: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeAnnotation(id = 'ann-1'): Annotation {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
documentId: 'doc-1',
|
||||||
|
pageNumber: 1,
|
||||||
|
x: 0.1,
|
||||||
|
y: 0.1,
|
||||||
|
width: 0.3,
|
||||||
|
height: 0.2,
|
||||||
|
color: '#ff0000',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AnnotationLayer', () => {
|
||||||
|
it('renders a colored element for each annotation', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
|
||||||
|
canAnnotate: false,
|
||||||
|
color: '#ff0000',
|
||||||
|
onDraw: () => {},
|
||||||
|
onDelete: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a delete button for each annotation when canAnnotate is true', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [makeAnnotation('ann-1')],
|
||||||
|
canAnnotate: true,
|
||||||
|
color: '#ff0000',
|
||||||
|
onDraw: () => {},
|
||||||
|
onDelete: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('button', { name: /annotation löschen/i }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete buttons when canAnnotate is false', async () => {
|
||||||
|
render(AnnotationLayer, {
|
||||||
|
annotations: [makeAnnotation('ann-1')],
|
||||||
|
canAnnotate: false,
|
||||||
|
color: '#ff0000',
|
||||||
|
onDraw: () => {},
|
||||||
|
onDelete: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
394
frontend/src/lib/components/CommentThread.svelte
Normal file
394
frontend/src/lib/components/CommentThread.svelte
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, untrack } from 'svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
type CommentReply = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Comment = {
|
||||||
|
id: string;
|
||||||
|
authorId: string | null;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
replies: CommentReply[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documentId: string;
|
||||||
|
annotationId?: string | null;
|
||||||
|
initialComments?: Comment[];
|
||||||
|
loadOnMount?: boolean;
|
||||||
|
canComment: boolean;
|
||||||
|
currentUserId: string | null;
|
||||||
|
canAdmin: boolean;
|
||||||
|
onCountChange?: (count: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
documentId,
|
||||||
|
annotationId = null,
|
||||||
|
initialComments = [],
|
||||||
|
loadOnMount = false,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
onCountChange
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let comments: Comment[] = $state(untrack(() => [...initialComments]));
|
||||||
|
let newText: string = $state('');
|
||||||
|
let replyingTo: string | null = $state(null);
|
||||||
|
let replyText: string = $state('');
|
||||||
|
let editingId: string | null = $state(null);
|
||||||
|
let editText: string = $state('');
|
||||||
|
let posting: boolean = $state(false);
|
||||||
|
|
||||||
|
const commentsBase = $derived(
|
||||||
|
annotationId
|
||||||
|
? `/api/documents/${documentId}/annotations/${annotationId}/comments`
|
||||||
|
: `/api/documents/${documentId}/comments`
|
||||||
|
);
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
if (minutes < 1) return 'gerade eben';
|
||||||
|
if (minutes < 60) return `vor ${minutes} Minute${minutes === 1 ? '' : 'n'}`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `vor ${hours} Stunde${hours === 1 ? '' : 'n'}`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasEdited(c: { createdAt: string; updatedAt: string }): boolean {
|
||||||
|
return c.updatedAt > c.createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canModify(c: { authorId: string | null }): boolean {
|
||||||
|
return (currentUserId != null && c.authorId === currentUserId) || canAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(commentsBase);
|
||||||
|
if (res.ok) {
|
||||||
|
comments = await res.json();
|
||||||
|
const total = comments.reduce((s, c) => s + 1 + c.replies.length, 0);
|
||||||
|
onCountChange?.(total);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postComment() {
|
||||||
|
const text = newText.trim();
|
||||||
|
if (!text || posting) return;
|
||||||
|
posting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(commentsBase, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: text })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
newText = '';
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postReply(threadId: string) {
|
||||||
|
const text = replyText.trim();
|
||||||
|
if (!text || posting) return;
|
||||||
|
posting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${commentsBase}/${threadId}/replies`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: text })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
replyText = '';
|
||||||
|
replyingTo = null;
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(commentId: string) {
|
||||||
|
const text = editText.trim();
|
||||||
|
if (!text || posting) return;
|
||||||
|
posting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: text })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
editingId = null;
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteComment(commentId: string) {
|
||||||
|
if (posting) return;
|
||||||
|
posting = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${documentId}/comments/${commentId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
await reload();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
posting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(comment: Comment | CommentReply) {
|
||||||
|
editingId = comment.id;
|
||||||
|
editText = comment.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReply(threadId: string) {
|
||||||
|
replyingTo = threadId;
|
||||||
|
replyText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelReply() {
|
||||||
|
replyingTo = null;
|
||||||
|
replyText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (loadOnMount) {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each comments as thread, ti (thread.id)}
|
||||||
|
<div class={ti > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
||||||
|
<!-- Root comment -->
|
||||||
|
<div>
|
||||||
|
{#if editingId === thread.id}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
rows={3}
|
||||||
|
bind:value={editText}
|
||||||
|
></textarea>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||||
|
disabled={posting}
|
||||||
|
onclick={() => saveEdit(thread.id)}
|
||||||
|
>
|
||||||
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-sans text-xs font-semibold text-brand-navy"
|
||||||
|
>{thread.authorName}</span
|
||||||
|
>
|
||||||
|
<span class="font-sans text-xs text-gray-400">{timeAgo(thread.createdAt)}</span>
|
||||||
|
{#if wasEdited(thread)}
|
||||||
|
<span class="font-sans text-xs text-gray-400">
|
||||||
|
{m.comment_edited_label()}
|
||||||
|
{timeAgo(thread.updatedAt)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{thread.content}</p>
|
||||||
|
</div>
|
||||||
|
{#if canModify(thread)}
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => startEdit(thread)}
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => deleteComment(thread.id)}
|
||||||
|
>
|
||||||
|
{m.btn_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- Reply button on root comment only if there are no replies -->
|
||||||
|
{#if thread.replies.length === 0 && canComment}
|
||||||
|
<div class="mt-1">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => startReply(thread.id)}
|
||||||
|
>
|
||||||
|
{m.comment_btn_reply()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Replies -->
|
||||||
|
{#each thread.replies as reply, ri (reply.id)}
|
||||||
|
<div class="mt-3 ml-6 border-l-2 border-brand-sand pl-4">
|
||||||
|
{#if editingId === reply.id}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
rows={3}
|
||||||
|
bind:value={editText}
|
||||||
|
></textarea>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||||
|
disabled={posting}
|
||||||
|
onclick={() => saveEdit(reply.id)}
|
||||||
|
>
|
||||||
|
{m.btn_save()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="font-sans text-xs font-semibold text-brand-navy"
|
||||||
|
>{reply.authorName}</span
|
||||||
|
>
|
||||||
|
<span class="font-sans text-xs text-gray-400">{timeAgo(reply.createdAt)}</span>
|
||||||
|
{#if wasEdited(reply)}
|
||||||
|
<span class="font-sans text-xs text-gray-400">
|
||||||
|
{m.comment_edited_label()}
|
||||||
|
{timeAgo(reply.updatedAt)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 font-serif text-sm leading-relaxed text-gray-700">{reply.content}</p>
|
||||||
|
</div>
|
||||||
|
{#if canModify(reply)}
|
||||||
|
<div class="flex shrink-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => startEdit(reply)}
|
||||||
|
>
|
||||||
|
{m.btn_edit()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => deleteComment(reply.id)}
|
||||||
|
>
|
||||||
|
{m.btn_delete()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<!-- Reply button only on the last reply -->
|
||||||
|
{#if ri === thread.replies.length - 1 && canComment}
|
||||||
|
<div class="mt-1">
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs font-medium text-brand-mint transition-colors hover:text-brand-navy"
|
||||||
|
onclick={() => startReply(thread.id)}
|
||||||
|
>
|
||||||
|
{m.comment_btn_reply()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Reply textarea (shown when replyingTo === thread.id) -->
|
||||||
|
{#if replyingTo === thread.id}
|
||||||
|
<div class="mt-3 ml-6 flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
rows={3}
|
||||||
|
placeholder={m.comment_placeholder()}
|
||||||
|
bind:value={replyText}
|
||||||
|
></textarea>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||||
|
disabled={posting}
|
||||||
|
onclick={() => postReply(thread.id)}
|
||||||
|
>
|
||||||
|
{m.comment_btn_post()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="font-sans text-xs text-gray-400 transition-colors hover:text-brand-navy"
|
||||||
|
onclick={cancelReply}
|
||||||
|
>
|
||||||
|
{m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- New top-level comment textarea -->
|
||||||
|
{#if canComment}
|
||||||
|
<div class={comments.length > 0 ? 'border-t border-brand-sand pt-4' : ''}>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<textarea
|
||||||
|
class="w-full resize-none rounded border border-brand-sand px-3 py-2 font-serif text-sm text-brand-navy focus:ring-1 focus:ring-brand-mint focus:outline-none"
|
||||||
|
rows={3}
|
||||||
|
placeholder={m.comment_placeholder()}
|
||||||
|
bind:value={newText}
|
||||||
|
></textarea>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="rounded bg-brand-navy px-3 py-1.5 font-sans text-xs font-medium text-white hover:bg-brand-navy/80 disabled:opacity-40"
|
||||||
|
disabled={posting || !newText.trim()}
|
||||||
|
onclick={postComment}
|
||||||
|
>
|
||||||
|
{m.comment_btn_post()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
33
frontend/src/lib/components/ExpandableText.svelte
Normal file
33
frontend/src/lib/components/ExpandableText.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let { text, maxLines = 10 }: { text: string; maxLines?: number } = $props();
|
||||||
|
|
||||||
|
let expanded = $state(false);
|
||||||
|
let el = $state<HTMLElement | undefined>(undefined);
|
||||||
|
let isClamped = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (el && !expanded) {
|
||||||
|
isClamped = el.scrollHeight > el.clientHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
bind:this={el}
|
||||||
|
style={!expanded ? `overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${maxLines}` : ''}
|
||||||
|
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
{#if isClamped || expanded}
|
||||||
|
<button
|
||||||
|
onclick={() => (expanded = !expanded)}
|
||||||
|
class="mt-2 font-sans text-xs text-gray-400 transition hover:text-brand-navy"
|
||||||
|
>
|
||||||
|
{expanded ? m.comp_expandable_show_less() : m.comp_expandable_show_more()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
501
frontend/src/lib/components/PdfViewer.svelte
Normal file
501
frontend/src/lib/components/PdfViewer.svelte
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { SvelteMap } from 'svelte/reactivity';
|
||||||
|
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
|
||||||
|
import AnnotationLayer from './AnnotationLayer.svelte';
|
||||||
|
import AnnotationCommentPanel from './AnnotationCommentPanel.svelte';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
url,
|
||||||
|
documentId = '',
|
||||||
|
canAnnotate = false,
|
||||||
|
canComment,
|
||||||
|
currentUserId,
|
||||||
|
canAdmin,
|
||||||
|
documentFileHash
|
||||||
|
}: {
|
||||||
|
url: string;
|
||||||
|
documentId?: string;
|
||||||
|
canAnnotate?: boolean;
|
||||||
|
canComment?: boolean;
|
||||||
|
currentUserId?: string | null;
|
||||||
|
canAdmin?: boolean;
|
||||||
|
documentFileHash?: string | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let pdfDoc = $state<PDFDocumentProxy | null>(null);
|
||||||
|
let currentPage = $state(1);
|
||||||
|
let totalPages = $state(0);
|
||||||
|
let scale = $state(1.5);
|
||||||
|
let loading = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Canvas and text layer container refs — bound via bind:this, not reactive state
|
||||||
|
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
||||||
|
let textLayerEl = $state<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// Internal mutable refs for in-flight tasks — NOT $state to avoid reactive loops
|
||||||
|
let renderTask: RenderTask | null = null;
|
||||||
|
let textLayerInstance: { cancel: () => void } | null = null;
|
||||||
|
|
||||||
|
// Holds the dynamically-loaded pdfjs module (browser-only)
|
||||||
|
// Not $state — we use pdfjsReady as the reactive trigger instead
|
||||||
|
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
|
||||||
|
let pdfjsReady = $state(false);
|
||||||
|
|
||||||
|
type Annotation = {
|
||||||
|
id: string;
|
||||||
|
documentId: string;
|
||||||
|
pageNumber: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
color: string;
|
||||||
|
createdAt: string;
|
||||||
|
fileHash?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let annotations = $state<Annotation[]>([]);
|
||||||
|
let annotateMode = $state(false);
|
||||||
|
let annotateColor = $state('#ffff00');
|
||||||
|
let commentCounts = new SvelteMap<string, number>();
|
||||||
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
|
|
||||||
|
const visibleAnnotations = $derived(
|
||||||
|
annotations.filter((a) => !a.fileHash || !documentFileHash || a.fileHash === documentFileHash)
|
||||||
|
);
|
||||||
|
const outdatedCount = $derived(annotations.length - visibleAnnotations.length);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Dynamic import keeps pdfjs out of the SSR bundle entirely
|
||||||
|
const [lib, { default: workerUrl }] = await Promise.all([
|
||||||
|
import('pdfjs-dist'),
|
||||||
|
import('pdfjs-dist/build/pdf.worker.min.mjs?url')
|
||||||
|
]);
|
||||||
|
lib.GlobalWorkerOptions.workerSrc = workerUrl;
|
||||||
|
pdfjsLib = lib;
|
||||||
|
pdfjsReady = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadDocument(src: string) {
|
||||||
|
if (!pdfjsLib) return;
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
pdfDoc = null;
|
||||||
|
currentPage = 1;
|
||||||
|
totalPages = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadingTask = pdfjsLib.getDocument(src);
|
||||||
|
const doc = await loadingTask.promise;
|
||||||
|
pdfDoc = doc;
|
||||||
|
totalPages = doc.numPages;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Failed to load PDF';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPage(doc: PDFDocumentProxy, pageNum: number) {
|
||||||
|
if (!pdfjsLib || !canvasEl || !textLayerEl) return;
|
||||||
|
|
||||||
|
// Cancel any in-flight render
|
||||||
|
if (renderTask) {
|
||||||
|
renderTask.cancel();
|
||||||
|
renderTask = null;
|
||||||
|
}
|
||||||
|
if (textLayerInstance) {
|
||||||
|
textLayerInstance.cancel();
|
||||||
|
textLayerInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let page: PDFPageProxy;
|
||||||
|
try {
|
||||||
|
page = await doc.getPage(pageNum);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const viewport = page.getViewport({ scale: scale * dpr });
|
||||||
|
|
||||||
|
const canvas = canvasEl;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.style.width = `${viewport.width / dpr}px`;
|
||||||
|
canvas.style.height = `${viewport.height / dpr}px`;
|
||||||
|
|
||||||
|
const task = page.render({ canvas, canvasContext: ctx, viewport });
|
||||||
|
renderTask = task;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await task.promise;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (
|
||||||
|
typeof e === 'object' &&
|
||||||
|
e !== null &&
|
||||||
|
'name' in e &&
|
||||||
|
(e as { name: string }).name === 'RenderingCancelledException'
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderTask = null;
|
||||||
|
|
||||||
|
// Text layer
|
||||||
|
const textDiv = textLayerEl;
|
||||||
|
textDiv.innerHTML = '';
|
||||||
|
textDiv.style.width = `${viewport.width / dpr}px`;
|
||||||
|
textDiv.style.height = `${viewport.height / dpr}px`;
|
||||||
|
|
||||||
|
const tl = new pdfjsLib.TextLayer({
|
||||||
|
textContentSource: page.streamTextContent(),
|
||||||
|
container: textDiv,
|
||||||
|
viewport
|
||||||
|
});
|
||||||
|
textLayerInstance = tl;
|
||||||
|
try {
|
||||||
|
await tl.render();
|
||||||
|
} catch {
|
||||||
|
// cancelled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prerender(doc: PDFDocumentProxy, pageNum: number) {
|
||||||
|
const neighbors = [pageNum - 1, pageNum + 1].filter((n) => n >= 1 && n <= doc.numPages);
|
||||||
|
for (const n of neighbors) {
|
||||||
|
try {
|
||||||
|
await doc.getPage(n);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommentCounts(docId: string, anns: Annotation[]) {
|
||||||
|
await Promise.all(
|
||||||
|
anns.map(async (a) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${docId}/annotations/${a.id}/comments`);
|
||||||
|
if (res.ok) {
|
||||||
|
const threads = (await res.json()) as Array<{ replies: unknown[] }>;
|
||||||
|
const total = threads.reduce((sum, t) => sum + 1 + t.replies.length, 0);
|
||||||
|
commentCounts.set(a.id, total);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAnnotations(docId: string) {
|
||||||
|
if (!docId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${docId}/annotations`);
|
||||||
|
if (res.ok) {
|
||||||
|
annotations = await res.json();
|
||||||
|
await loadCommentCounts(docId, annotations);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
|
||||||
|
if (!documentId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${documentId}/annotations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pageNumber: currentPage,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
color: annotateColor
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const created: Annotation = await res.json();
|
||||||
|
annotations = [...annotations, created];
|
||||||
|
activeAnnotationId = created.id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAnnotationDelete(annotationId: string) {
|
||||||
|
if (!documentId) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
annotations = annotations.filter((a) => a.id !== annotationId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (pdfjsReady && url) {
|
||||||
|
loadDocument(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Read scale synchronously so Svelte tracks it as a dependency.
|
||||||
|
// Without this, zoom changes don't re-trigger the effect because
|
||||||
|
// scale is only read inside the async renderPage call.
|
||||||
|
if (pdfDoc && currentPage && scale > 0) {
|
||||||
|
renderPage(pdfDoc, currentPage).then(() => {
|
||||||
|
if (pdfDoc) prerender(pdfDoc, currentPage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (documentId) {
|
||||||
|
loadAnnotations(documentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (currentPage > 1) currentPage -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (pdfDoc && currentPage < pdfDoc.numPages) currentPage += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
scale += 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
if (scale > 0.5) scale -= 0.25;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if !url}
|
||||||
|
<div class="flex h-full w-full items-center justify-center bg-[#2A2A2A] text-gray-400">
|
||||||
|
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-[#2A2A2A] text-gray-300"
|
||||||
|
>
|
||||||
|
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="font-sans text-xs text-brand-mint underline hover:opacity-80"
|
||||||
|
>
|
||||||
|
Direkt öffnen
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex h-full w-full flex-col bg-[#2A2A2A]">
|
||||||
|
{#if outdatedCount > 0}
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center gap-2 border-b border-amber-500/30 bg-amber-500/10 px-4 py-2"
|
||||||
|
data-testid="annotation-outdated-notice"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4 shrink-0 text-amber-400"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-sans text-xs text-amber-300">{m.annotation_outdated_notice()}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Controls -->
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center justify-between gap-2 border-b border-white/10 px-4 py-2"
|
||||||
|
>
|
||||||
|
<!-- Page navigation -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onclick={prevPage}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
aria-label="Zurück"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if totalPages > 0}
|
||||||
|
<span class="font-sans text-xs text-gray-300 tabular-nums">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={nextPage}
|
||||||
|
disabled={!pdfDoc || currentPage >= totalPages}
|
||||||
|
aria-label="Weiter"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom controls -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={zoomOut}
|
||||||
|
aria-label="Verkleinern"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" /><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M21 21l-4.35-4.35M8 11h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={zoomIn}
|
||||||
|
aria-label="Vergrößern"
|
||||||
|
class="rounded p-1 text-gray-300 transition hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-4 w-4"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" /><path
|
||||||
|
stroke-linecap="round"
|
||||||
|
d="M21 21l-4.35-4.35M11 8v6M8 11h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Annotate controls -->
|
||||||
|
{#if canAnnotate}
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onclick={() => (annotateMode = !annotateMode)}
|
||||||
|
aria-label={annotateMode ? 'Annotieren beenden' : 'Annotieren'}
|
||||||
|
class="rounded px-2 py-1 font-sans text-xs text-gray-300 transition hover:bg-white/10 {annotateMode ? 'bg-white/20' : ''}"
|
||||||
|
>
|
||||||
|
{annotateMode ? 'Fertig' : 'Annotieren'}
|
||||||
|
</button>
|
||||||
|
{#if annotateMode}
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
bind:value={annotateColor}
|
||||||
|
aria-label="Farbe wählen"
|
||||||
|
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
|
||||||
|
title="Farbe wählen"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
title="Sie benötigen die Berechtigung ANNOTATE_ALL zum Annotieren"
|
||||||
|
class="cursor-not-allowed rounded px-2 py-1 font-sans text-xs text-gray-500"
|
||||||
|
aria-label="Annotieren (keine Berechtigung)"
|
||||||
|
>
|
||||||
|
Annotieren
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PDF canvas area -->
|
||||||
|
<div class="relative flex-1 overflow-auto">
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex h-full items-center justify-center">
|
||||||
|
<div
|
||||||
|
class="h-8 w-8 animate-spin rounded-full border-2 border-white/20 border-t-white"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex min-h-full items-start justify-center p-4">
|
||||||
|
<div
|
||||||
|
class="pdf-page relative shadow-xl"
|
||||||
|
data-page-number={currentPage}
|
||||||
|
style="position: relative"
|
||||||
|
>
|
||||||
|
<canvas bind:this={canvasEl}></canvas>
|
||||||
|
<div
|
||||||
|
bind:this={textLayerEl}
|
||||||
|
class="textLayer"
|
||||||
|
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
|
||||||
|
></div>
|
||||||
|
<AnnotationLayer
|
||||||
|
annotations={visibleAnnotations.filter((a) => a.pageNumber === currentPage)}
|
||||||
|
canAnnotate={annotateMode}
|
||||||
|
color={annotateColor}
|
||||||
|
onDraw={handleAnnotationDraw}
|
||||||
|
onDelete={handleAnnotationDelete}
|
||||||
|
commentCounts={Object.fromEntries(commentCounts)}
|
||||||
|
onAnnotationClick={(id) => (activeAnnotationId = id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#key activeAnnotationId}
|
||||||
|
{#if activeAnnotationId}
|
||||||
|
<AnnotationCommentPanel
|
||||||
|
documentId={documentId}
|
||||||
|
annotationId={activeAnnotationId}
|
||||||
|
canComment={canComment ?? false}
|
||||||
|
currentUserId={currentUserId ?? null}
|
||||||
|
canAdmin={canAdmin ?? false}
|
||||||
|
onClose={() => (activeAnnotationId = null)}
|
||||||
|
onCountChange={(count) => {
|
||||||
|
if (activeAnnotationId) commentCounts.set(activeAnnotationId, count);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
53
frontend/src/lib/components/PdfViewer.svelte.spec.ts
Normal file
53
frontend/src/lib/components/PdfViewer.svelte.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { vi, describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
|
||||||
|
// pdfjs-dist is a rendering dependency — we mock it so unit tests don't need
|
||||||
|
// a real browser PDF engine. The interesting behaviour under test here is the
|
||||||
|
// component's own UI logic (controls, page counter), not pdfjs internals.
|
||||||
|
vi.mock('pdfjs-dist', () => {
|
||||||
|
function TextLayerMock() {}
|
||||||
|
TextLayerMock.prototype.render = () => Promise.resolve();
|
||||||
|
TextLayerMock.prototype.cancel = () => {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
GlobalWorkerOptions: { workerSrc: '' },
|
||||||
|
getDocument: vi.fn().mockReturnValue({
|
||||||
|
promise: Promise.resolve({
|
||||||
|
numPages: 2,
|
||||||
|
getPage: vi.fn().mockResolvedValue({
|
||||||
|
getViewport: vi.fn().mockReturnValue({ width: 595, height: 842 }),
|
||||||
|
render: vi.fn().mockReturnValue({ promise: Promise.resolve() }),
|
||||||
|
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
TextLayer: TextLayerMock
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('pdfjs-dist/build/pdf.worker.min.mjs?url', () => ({ default: '' }));
|
||||||
|
|
||||||
|
import PdfViewer from './PdfViewer.svelte';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('PdfViewer', () => {
|
||||||
|
it('shows previous and next page navigation buttons', async () => {
|
||||||
|
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||||
|
await expect.element(page.getByRole('button', { name: /zurück/i })).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: /weiter/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows zoom controls', async () => {
|
||||||
|
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||||
|
await expect.element(page.getByRole('button', { name: /vergrößern/i })).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByRole('button', { name: /verkleinern/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the page counter once the PDF has loaded', async () => {
|
||||||
|
render(PdfViewer, { url: '/api/documents/test-id/file' });
|
||||||
|
// Mock resolves synchronously, so "1 / 2" should appear quickly
|
||||||
|
await expect.element(page.getByText(/1\s*\/\s*2/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,9 @@ export type ErrorCode =
|
|||||||
| 'WRONG_CURRENT_PASSWORD'
|
| 'WRONG_CURRENT_PASSWORD'
|
||||||
| 'IMPORT_ALREADY_RUNNING'
|
| 'IMPORT_ALREADY_RUNNING'
|
||||||
| 'INVALID_RESET_TOKEN'
|
| 'INVALID_RESET_TOKEN'
|
||||||
|
| 'ANNOTATION_NOT_FOUND'
|
||||||
|
| 'ANNOTATION_OVERLAP'
|
||||||
|
| 'COMMENT_NOT_FOUND'
|
||||||
| 'UNAUTHORIZED'
|
| 'UNAUTHORIZED'
|
||||||
| 'FORBIDDEN'
|
| 'FORBIDDEN'
|
||||||
| 'VALIDATION_ERROR'
|
| 'VALIDATION_ERROR'
|
||||||
@@ -61,6 +64,12 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_import_already_running();
|
return m.error_import_already_running();
|
||||||
case 'INVALID_RESET_TOKEN':
|
case 'INVALID_RESET_TOKEN':
|
||||||
return m.error_invalid_reset_token();
|
return m.error_invalid_reset_token();
|
||||||
|
case 'ANNOTATION_NOT_FOUND':
|
||||||
|
return m.error_annotation_not_found();
|
||||||
|
case 'ANNOTATION_OVERLAP':
|
||||||
|
return m.error_annotation_overlap();
|
||||||
|
case 'COMMENT_NOT_FOUND':
|
||||||
|
return m.error_comment_not_found();
|
||||||
case 'UNAUTHORIZED':
|
case 'UNAUTHORIZED':
|
||||||
return m.error_unauthorized();
|
return m.error_unauthorized();
|
||||||
case 'FORBIDDEN':
|
case 'FORBIDDEN':
|
||||||
|
|||||||
@@ -180,6 +180,86 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/comments": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getDocumentComments"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["postDocumentComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/comments/{commentId}/replies": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["replyToDocumentComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/annotations": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listAnnotations"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createAnnotation"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/annotations/{annotationId}/comments": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getAnnotationComments"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["postAnnotationComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/documents/{documentId}/annotations/{annotationId}/comments/{commentId}/replies": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["replyToAnnotationComment"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/auth/reset-password": {
|
"/api/auth/reset-password": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -228,6 +308,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/backfill-versions": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["backfillVersions"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/groups/{id}": {
|
"/api/groups/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -244,6 +340,22 @@ export interface paths {
|
|||||||
patch: operations["updateGroup"];
|
patch: operations["updateGroup"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/comments/{commentId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteComment"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch: operations["editComment"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/tags": {
|
"/api/tags": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -404,6 +516,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/documents/{documentId}/annotations/{annotationId}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deleteAnnotation"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -494,6 +622,7 @@ export interface components {
|
|||||||
title: string;
|
title: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
|
fileHash?: string;
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
status: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED";
|
||||||
@@ -532,6 +661,63 @@ export interface components {
|
|||||||
name?: string;
|
name?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
};
|
};
|
||||||
|
CreateCommentDTO: {
|
||||||
|
content?: string;
|
||||||
|
};
|
||||||
|
DocumentComment: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
documentId: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
annotationId?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
parentId?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
authorId?: string;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updatedAt: string;
|
||||||
|
replies: components["schemas"]["DocumentComment"][];
|
||||||
|
};
|
||||||
|
CreateAnnotationDTO: {
|
||||||
|
/** Format: int32 */
|
||||||
|
pageNumber?: number;
|
||||||
|
/** Format: double */
|
||||||
|
x?: number;
|
||||||
|
/** Format: double */
|
||||||
|
y?: number;
|
||||||
|
/** Format: double */
|
||||||
|
width?: number;
|
||||||
|
/** Format: double */
|
||||||
|
height?: number;
|
||||||
|
color?: string;
|
||||||
|
};
|
||||||
|
DocumentAnnotation: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
documentId: string;
|
||||||
|
/** Format: int32 */
|
||||||
|
pageNumber: number;
|
||||||
|
/** Format: double */
|
||||||
|
x: number;
|
||||||
|
/** Format: double */
|
||||||
|
y: number;
|
||||||
|
/** Format: double */
|
||||||
|
width: number;
|
||||||
|
/** Format: double */
|
||||||
|
height: number;
|
||||||
|
color: string;
|
||||||
|
fileHash?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
createdBy?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
ResetPasswordRequest: {
|
ResetPasswordRequest: {
|
||||||
token?: string;
|
token?: string;
|
||||||
newPassword?: string;
|
newPassword?: string;
|
||||||
@@ -548,6 +734,10 @@ export interface components {
|
|||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
startedAt?: string;
|
startedAt?: string;
|
||||||
};
|
};
|
||||||
|
BackfillResult: {
|
||||||
|
/** Format: int32 */
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
DocumentVersionSummary: {
|
DocumentVersionSummary: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1042,6 +1232,205 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getDocumentComments: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
postDocumentComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
replyToDocumentComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listAnnotations: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentAnnotation"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createAnnotation: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateAnnotationDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentAnnotation"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getAnnotationComments: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
annotationId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
postAnnotationComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
annotationId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
replyToAnnotationComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Created */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
resetPassword: {
|
resetPassword: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1106,6 +1495,26 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
backfillVersions: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["BackfillResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
deleteGroup: {
|
deleteGroup: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1152,6 +1561,54 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deleteComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
editComment: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
commentId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CreateCommentDTO"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"*/*": components["schemas"]["DocumentComment"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
searchTags: {
|
searchTags: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -1382,4 +1839,25 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deleteAnnotation: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
documentId: string;
|
||||||
|
annotationId: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description No Content */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||||
|
const groups: { permissions: string[] }[] = locals.user?.groups ?? [];
|
||||||
return {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
canWrite:
|
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
|
||||||
locals.user?.groups?.some((g: { permissions: string[] }) =>
|
canAnnotate: groups.some((g) => g.permissions.includes('ANNOTATE_ALL'))
|
||||||
g.permissions.includes('WRITE_ALL')
|
|
||||||
) ?? false
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -250,16 +250,6 @@ $effect(() => {
|
|||||||
>
|
>
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Status Badge -->
|
|
||||||
<span
|
|
||||||
class="ml-3 inline-flex items-center rounded-full border px-2.5 py-0.5 text-[10px] font-bold tracking-wide uppercase
|
|
||||||
{doc.status === 'UPLOADED'
|
|
||||||
? 'border-brand-mint/50 bg-brand-mint/20 text-brand-navy'
|
|
||||||
: 'border-yellow-200 bg-yellow-50 text-yellow-700'}"
|
|
||||||
>
|
|
||||||
{doc.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Metadata Row -->
|
<!-- Metadata Row -->
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ let activeTab = $state('users');
|
|||||||
let editingTagId: string | null = $state(null);
|
let editingTagId: string | null = $state(null);
|
||||||
let editingTagName = $state('');
|
let editingTagName = $state('');
|
||||||
let editingGroupId: string | null = $state(null);
|
let editingGroupId: string | null = $state(null);
|
||||||
|
let backfillResult: number | null = $state(null);
|
||||||
|
let backfillLoading = $state(false);
|
||||||
|
let backfillHashesResult: number | null = $state(null);
|
||||||
|
let backfillHashesLoading = $state(false);
|
||||||
|
|
||||||
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
const availablePermissions = ['WRITE_ALL', 'ADMIN', 'ADMIN_USER', 'ADMIN_TAG', 'ADMIN_PERMISSION'];
|
||||||
|
|
||||||
@@ -29,6 +33,34 @@ function startEditGroup(id: string) {
|
|||||||
function cancelEditGroup() {
|
function cancelEditGroup() {
|
||||||
editingGroupId = null;
|
editingGroupId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function backfillVersions() {
|
||||||
|
backfillLoading = true;
|
||||||
|
backfillResult = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/backfill-versions', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
backfillResult = data.count;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backfillLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillFileHashes() {
|
||||||
|
backfillHashesLoading = true;
|
||||||
|
backfillHashesResult = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/backfill-file-hashes', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
backfillHashesResult = data.count;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
backfillHashesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
<div class="mx-auto max-w-7xl py-8 font-sans sm:px-6 lg:px-8">
|
||||||
@@ -58,6 +90,13 @@ function cancelEditGroup() {
|
|||||||
: 'text-gray-500 hover:text-brand-navy'}"
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
|
onclick={() => (activeTab = 'tags')}>{m.admin_tab_tags()}</button
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
class="rounded-md px-4 py-2 text-sm font-bold tracking-wide uppercase transition {activeTab ===
|
||||||
|
'system'
|
||||||
|
? 'bg-brand-navy text-white'
|
||||||
|
: 'text-gray-500 hover:text-brand-navy'}"
|
||||||
|
onclick={() => (activeTab = 'system')}>{m.admin_tab_system()}</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -495,5 +534,41 @@ function cancelEditGroup() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeTab === 'system'}
|
||||||
|
<div class="rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-gray-700">{m.admin_system_backfill_heading()}</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_description()}</p>
|
||||||
|
<button
|
||||||
|
onclick={backfillVersions}
|
||||||
|
disabled={backfillLoading}
|
||||||
|
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-brand-mint hover:text-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{backfillLoading ? '…' : m.admin_system_backfill_btn()}
|
||||||
|
</button>
|
||||||
|
{#if backfillResult !== null}
|
||||||
|
<p class="mt-4 text-sm font-medium text-brand-navy">
|
||||||
|
{m.admin_system_backfill_success({ count: backfillResult })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-sm border border-brand-sand bg-white p-6 shadow-sm">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-gray-700">
|
||||||
|
{m.admin_system_backfill_hashes_heading()}
|
||||||
|
</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-500">{m.admin_system_backfill_hashes_description()}</p>
|
||||||
|
<button
|
||||||
|
onclick={backfillFileHashes}
|
||||||
|
disabled={backfillHashesLoading}
|
||||||
|
class="rounded bg-brand-navy px-6 py-2 text-sm font-bold text-white uppercase transition hover:bg-brand-mint hover:text-brand-navy disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{backfillHashesLoading ? '…' : m.admin_system_backfill_hashes_btn()}
|
||||||
|
</button>
|
||||||
|
{#if backfillHashesResult !== null}
|
||||||
|
<p class="mt-4 text-sm font-medium text-brand-navy">
|
||||||
|
{m.admin_system_backfill_hashes_success({ count: backfillHashesResult })}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const makeUser = (overrides = {}) => ({
|
|||||||
const baseData = {
|
const baseData = {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
|
canAnnotate: false,
|
||||||
users: [makeUser()],
|
users: [makeUser()],
|
||||||
groups: [makeGroup()],
|
groups: [makeGroup()],
|
||||||
tags: []
|
tags: []
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ const makeUser = (overrides = {}) => ({
|
|||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseData = { user: undefined, canWrite: true, editUser: makeUser(), groups };
|
const baseData = {
|
||||||
|
user: undefined,
|
||||||
|
canWrite: true,
|
||||||
|
canAnnotate: false,
|
||||||
|
editUser: makeUser(),
|
||||||
|
groups
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const groups = [
|
|||||||
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
|
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const baseData = { user: undefined, canWrite: true, groups };
|
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ afterEach(cleanup);
|
|||||||
const baseData = {
|
const baseData = {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
|
canAnnotate: false,
|
||||||
documents: [],
|
documents: [],
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
initialValues: { senderName: '', receiverName: '' },
|
||||||
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
const base = env.API_INTERNAL_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
const result = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
const [docResult, commentsRes] = await Promise.all([
|
||||||
|
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
||||||
|
fetch(`${base}/api/documents/${id}/comments`).catch(() => null)
|
||||||
|
]);
|
||||||
|
|
||||||
if (result.response.status === 401) throw redirect(302, '/login');
|
if (docResult.response.status === 401) throw redirect(302, '/login');
|
||||||
|
|
||||||
if (!result.response.ok) {
|
if (!docResult.response.ok) {
|
||||||
const code = (result.error as unknown as { code?: string })?.code;
|
const code = (docResult.error as unknown as { code?: string })?.code;
|
||||||
throw error(result.response.status, getErrorMessage(code));
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { document: result.data! };
|
let comments: unknown[] = [];
|
||||||
|
if (commentsRes?.ok) {
|
||||||
|
try {
|
||||||
|
comments = await commentsRes.json();
|
||||||
|
} catch {
|
||||||
|
// ignore invalid response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { document: docResult.data!, comments };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,20 @@
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/utils/date';
|
import { formatDate } from '$lib/utils/date';
|
||||||
import { diffWords } from 'diff';
|
import { diffWords } from 'diff';
|
||||||
|
import ExpandableText from '$lib/components/ExpandableText.svelte';
|
||||||
|
import PdfViewer from '$lib/components/PdfViewer.svelte';
|
||||||
|
import CommentThread from '$lib/components/CommentThread.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const doc = $derived(data.document);
|
const doc = $derived(data.document);
|
||||||
|
const canComment = $derived((data.canAnnotate || data.canWrite) ?? false);
|
||||||
|
const canAdmin = $derived(
|
||||||
|
(data.user?.groups as Array<{ permissions: string[] }> | undefined)?.some((g) =>
|
||||||
|
g.permissions.includes('ADMIN')
|
||||||
|
) ?? false
|
||||||
|
);
|
||||||
|
const currentUserId = $derived((data.user?.id as string | undefined) ?? null);
|
||||||
|
|
||||||
let fileUrl = $state('');
|
let fileUrl = $state('');
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
@@ -112,6 +122,44 @@ function personLabel(p: { firstName: string; lastName: string }): string {
|
|||||||
return `${p.firstName} ${p.lastName}`.trim();
|
return `${p.firstName} ${p.lastName}`.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DIFF_CONTEXT_WORDS = 4;
|
||||||
|
|
||||||
|
type DiffPart = { value: string; added?: boolean; removed?: boolean };
|
||||||
|
|
||||||
|
function trimContextParts(parts: DiffPart[]): DiffPart[] {
|
||||||
|
return parts.flatMap((part, i) => {
|
||||||
|
if (part.added || part.removed) return [part];
|
||||||
|
const tokens = part.value.split(/(\s+)/).filter(Boolean);
|
||||||
|
const wordCount = tokens.filter((t) => /\S/.test(t)).length;
|
||||||
|
if (wordCount <= DIFF_CONTEXT_WORDS * 2) return [part];
|
||||||
|
|
||||||
|
function keepFirst(n: number): string {
|
||||||
|
let count = 0;
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const t of tokens) {
|
||||||
|
out.push(t);
|
||||||
|
if (/\S/.test(t) && ++count >= n) break;
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
function keepLast(n: number): string {
|
||||||
|
let count = 0;
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const t of [...tokens].reverse()) {
|
||||||
|
out.unshift(t);
|
||||||
|
if (/\S/.test(t) && ++count >= n) break;
|
||||||
|
}
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFirst = i === 0;
|
||||||
|
const isLast = i === parts.length - 1;
|
||||||
|
if (isFirst) return [{ value: '… ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||||
|
if (isLast) return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' …' }];
|
||||||
|
return [{ value: keepFirst(DIFF_CONTEXT_WORDS) + ' … ' + keepLast(DIFF_CONTEXT_WORDS) }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
||||||
const entries: DiffEntry[] = [];
|
const entries: DiffEntry[] = [];
|
||||||
|
|
||||||
@@ -119,7 +167,7 @@ function buildDiff(older: SnapshotDoc | null, newer: SnapshotDoc): DiffEntry[] {
|
|||||||
const a = older?.[field] ?? '';
|
const a = older?.[field] ?? '';
|
||||||
const b = newer[field] ?? '';
|
const b = newer[field] ?? '';
|
||||||
if (a === b) continue;
|
if (a === b) continue;
|
||||||
const parts = diffWords(a, b);
|
const parts = trimContextParts(diffWords(a, b));
|
||||||
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
entries.push({ kind: 'text', field, label: fieldLabels[field](), parts });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +265,7 @@ async function selectVersion(versionId: string) {
|
|||||||
try {
|
try {
|
||||||
const idx = versions.findIndex((v) => v.id === versionId);
|
const idx = versions.findIndex((v) => v.id === versionId);
|
||||||
const newerSnap = await fetchSnapshot(versionId);
|
const newerSnap = await fetchSnapshot(versionId);
|
||||||
const olderSnap = idx + 1 < versions.length ? await fetchSnapshot(versions[idx + 1].id) : null;
|
const olderSnap = idx > 0 ? await fetchSnapshot(versions[idx - 1].id) : null;
|
||||||
const entries = buildDiff(olderSnap, newerSnap);
|
const entries = buildDiff(olderSnap, newerSnap);
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
noDiff = true;
|
noDiff = true;
|
||||||
@@ -268,7 +316,7 @@ function formatDateTime(iso: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function versionLabel(v: VersionSummary, index: number): string {
|
function versionLabel(v: VersionSummary, index: number): string {
|
||||||
return `Version ${versions.length - index} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
return `Version ${index + 1} — ${v.editorName} — ${formatDateTime(v.savedAt)}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -299,14 +347,6 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
<h1 class="truncate font-serif text-xl text-brand-navy" title={doc.title}>
|
<h1 class="truncate font-serif text-xl text-brand-navy" title={doc.title}>
|
||||||
{doc.title || doc.originalFilename}
|
{doc.title || doc.originalFilename}
|
||||||
</h1>
|
</h1>
|
||||||
<span
|
|
||||||
class="flex-shrink-0 rounded-full px-3 py-1 font-sans text-xs font-bold tracking-wide uppercase
|
|
||||||
{doc.status === 'UPLOADED'
|
|
||||||
? 'border border-brand-mint bg-brand-mint/30 text-brand-navy'
|
|
||||||
: 'border border-yellow-200 bg-yellow-100 text-yellow-800'}"
|
|
||||||
>
|
|
||||||
{doc.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -554,11 +594,7 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
>{m.doc_label_summary()}</span
|
>{m.doc_label_summary()}</span
|
||||||
>
|
>
|
||||||
<div
|
<ExpandableText text={doc.summary} />
|
||||||
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
|
||||||
>
|
|
||||||
{doc.summary}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -567,11 +603,7 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
<span class="mb-2 block font-sans text-xs text-gray-400 uppercase"
|
||||||
>{m.form_label_transcription()}</span
|
>{m.form_label_transcription()}</span
|
||||||
>
|
>
|
||||||
<div
|
<ExpandableText text={doc.transcription} />
|
||||||
class="rounded border border-brand-sand bg-brand-sand/30 p-5 font-serif text-sm leading-relaxed whitespace-pre-wrap text-brand-navy"
|
|
||||||
>
|
|
||||||
{doc.transcription}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -689,7 +721,7 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
>
|
>
|
||||||
<div class="flex items-baseline justify-between gap-2">
|
<div class="flex items-baseline justify-between gap-2">
|
||||||
<span class="font-sans text-xs font-medium text-brand-navy">
|
<span class="font-sans text-xs font-medium text-brand-navy">
|
||||||
Version {versions.length - i}
|
Version {i + 1}
|
||||||
</span>
|
</span>
|
||||||
<span class="font-sans text-[10px] text-gray-400">
|
<span class="font-sans text-[10px] text-gray-400">
|
||||||
{formatDateTime(v.savedAt)}
|
{formatDateTime(v.savedAt)}
|
||||||
@@ -789,6 +821,24 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. DISKUSSION -->
|
||||||
|
<div>
|
||||||
|
<div class="border-b border-brand-sand pb-2">
|
||||||
|
<h3 class="font-sans text-xs font-bold tracking-widest text-brand-navy uppercase">
|
||||||
|
{m.comment_section_title()}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentThread
|
||||||
|
documentId={doc.id}
|
||||||
|
initialComments={data.comments ?? []}
|
||||||
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
<div class="border-t border-brand-sand pt-4 font-sans text-[10px] text-gray-400">
|
||||||
<p class="truncate">ID: {doc.id}</p>
|
<p class="truncate">ID: {doc.id}</p>
|
||||||
@@ -842,12 +892,16 @@ function versionLabel(v: VersionSummary, index: number): string {
|
|||||||
</div>
|
</div>
|
||||||
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if fileUrl && doc.originalFilename.toLowerCase().endsWith('.pdf')}
|
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
|
||||||
<iframe
|
<PdfViewer
|
||||||
src={fileUrl}
|
url={fileUrl}
|
||||||
title={m.doc_preview_iframe_title()}
|
documentId={doc.id}
|
||||||
class="h-full w-full border-none bg-white"
|
canAnnotate={data.canAnnotate}
|
||||||
></iframe>
|
canComment={canComment}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
canAdmin={canAdmin}
|
||||||
|
documentFileHash={doc.fileHash ?? null}
|
||||||
|
/>
|
||||||
{:else if fileUrl}
|
{:else if fileUrl}
|
||||||
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ afterEach(cleanup);
|
|||||||
const baseData = {
|
const baseData = {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
|
canAnnotate: false,
|
||||||
persons: [],
|
persons: [],
|
||||||
initialSenderId: '',
|
initialSenderId: '',
|
||||||
initialSenderName: '',
|
initialSenderName: '',
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const makeData = (overrides = {}) => ({
|
|||||||
createdAt: ''
|
createdAt: ''
|
||||||
},
|
},
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
|
canAnnotate: false,
|
||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ afterEach(cleanup);
|
|||||||
const emptyData = {
|
const emptyData = {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
|
canAnnotate: false,
|
||||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||||
documents: [],
|
documents: [],
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
initialValues: { senderName: '', receiverName: '' },
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const makePerson = (overrides = {}) => ({
|
|||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
const emptyData = { user: undefined, canWrite: true, q: '', persons: [] };
|
const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] };
|
||||||
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
|
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
|
||||||
|
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { playwright } from '@vitest/browser-playwright';
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['pdfjs-dist']
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
||||||
port: 5173, // Standard SvelteKit Port
|
port: 5173, // Standard SvelteKit Port
|
||||||
@@ -13,7 +16,19 @@ export default defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.API_PROXY_TARGET || 'http://localhost:8080',
|
target: process.env.API_PROXY_TARGET || 'http://localhost:8080',
|
||||||
changeOrigin: true
|
changeOrigin: true,
|
||||||
|
// Inject Authorization header from the auth_token cookie so that
|
||||||
|
// browser-side fetch('/api/...') calls work the same as SSR fetches
|
||||||
|
// (which go through handleFetch in hooks.server.ts).
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq, req) => {
|
||||||
|
const cookies = req.headers.cookie ?? '';
|
||||||
|
const match = cookies.match(/auth_token=([^;]+)/);
|
||||||
|
if (match) {
|
||||||
|
proxyReq.setHeader('Authorization', decodeURIComponent(match[1]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -207,6 +207,28 @@
|
|||||||
resolved "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz"
|
resolved "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz"
|
||||||
integrity sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==
|
integrity sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==
|
||||||
|
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu@0.1.97":
|
||||||
|
version "0.1.97"
|
||||||
|
resolved "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz"
|
||||||
|
integrity sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==
|
||||||
|
|
||||||
|
"@napi-rs/canvas@^0.1.95":
|
||||||
|
version "0.1.97"
|
||||||
|
resolved "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz"
|
||||||
|
integrity sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==
|
||||||
|
optionalDependencies:
|
||||||
|
"@napi-rs/canvas-android-arm64" "0.1.97"
|
||||||
|
"@napi-rs/canvas-darwin-arm64" "0.1.97"
|
||||||
|
"@napi-rs/canvas-darwin-x64" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-arm-gnueabihf" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-arm64-gnu" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-arm64-musl" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-riscv64-gnu" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-x64-gnu" "0.1.97"
|
||||||
|
"@napi-rs/canvas-linux-x64-musl" "0.1.97"
|
||||||
|
"@napi-rs/canvas-win32-arm64-msvc" "0.1.97"
|
||||||
|
"@napi-rs/canvas-win32-x64-msvc" "0.1.97"
|
||||||
|
|
||||||
"@playwright/test@^1.58.2":
|
"@playwright/test@^1.58.2":
|
||||||
version "1.58.2"
|
version "1.58.2"
|
||||||
resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz"
|
resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz"
|
||||||
@@ -1462,6 +1484,11 @@ natural-compare@^1.4.0:
|
|||||||
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
|
||||||
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
|
||||||
|
|
||||||
|
node-readable-to-web-readable-stream@^0.4.2:
|
||||||
|
version "0.4.2"
|
||||||
|
resolved "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz"
|
||||||
|
integrity sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==
|
||||||
|
|
||||||
obug@^2.1.0, obug@^2.1.1:
|
obug@^2.1.0, obug@^2.1.1:
|
||||||
version "2.1.1"
|
version "2.1.1"
|
||||||
resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz"
|
resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz"
|
||||||
@@ -1553,6 +1580,14 @@ pathe@^2.0.3:
|
|||||||
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
|
resolved "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz"
|
||||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||||
|
|
||||||
|
pdfjs-dist@^5.5.207:
|
||||||
|
version "5.5.207"
|
||||||
|
resolved "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz"
|
||||||
|
integrity sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==
|
||||||
|
optionalDependencies:
|
||||||
|
"@napi-rs/canvas" "^0.1.95"
|
||||||
|
node-readable-to-web-readable-stream "^0.4.2"
|
||||||
|
|
||||||
picocolors@^1.0.0, picocolors@^1.1.1:
|
picocolors@^1.0.0, picocolors@^1.1.1:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"
|
resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"
|
||||||
|
|||||||
21
scripts/rebuild-frontend.sh
Executable file
21
scripts/rebuild-frontend.sh
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Rebuilds the frontend Docker container and refreshes the node_modules volume.
|
||||||
|
# Run this after adding or updating npm dependencies.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "Stopping frontend container..."
|
||||||
|
docker compose stop frontend
|
||||||
|
|
||||||
|
echo "Removing frontend container..."
|
||||||
|
docker compose rm -f frontend
|
||||||
|
|
||||||
|
echo "Removing stale node_modules volume..."
|
||||||
|
docker volume rm familienarchiv_frontend_node_modules 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "Rebuilding image and starting container..."
|
||||||
|
docker compose up -d --build frontend
|
||||||
|
|
||||||
|
echo "Done. Tailing logs (Ctrl+C to exit)..."
|
||||||
|
docker compose logs -f frontend
|
||||||
Reference in New Issue
Block a user