Compare commits
8 Commits
b6466fcd95
...
5b15991cf3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b15991cf3 | ||
|
|
99247ed58d | ||
|
|
714f00ef9d | ||
|
|
9e0b72bc10 | ||
|
|
c678432d25 | ||
|
|
19832dc1e0 | ||
|
|
b3013c42c0 | ||
|
|
cb02dc84f6 |
@@ -17,7 +17,6 @@ import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -28,6 +27,7 @@ import org.raddatz.familienarchiv.model.DocumentVersion;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
||||
import org.raddatz.familienarchiv.service.DocumentService;
|
||||
import org.raddatz.familienarchiv.service.DocumentVersionService;
|
||||
import org.raddatz.familienarchiv.service.FileService;
|
||||
@@ -197,12 +197,6 @@ public class DocumentController {
|
||||
return Map.of("count", documentService.getIncompleteCount());
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete")
|
||||
public List<IncompleteDocumentDTO> getIncomplete(
|
||||
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
|
||||
return documentService.findIncompleteDocuments(size);
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete/next")
|
||||
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||
return documentService.findNextIncompleteDocument(excludeId)
|
||||
@@ -210,12 +204,6 @@ public class DocumentController {
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@GetMapping("/recent-activity")
|
||||
public ResponseEntity<List<Document>> getRecentActivity(
|
||||
@RequestParam(defaultValue = "5") int size) {
|
||||
return ResponseEntity.ok(documentService.getRecentActivity(size));
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<DocumentSearchResult> search(
|
||||
@RequestParam(required = false) String q,
|
||||
@@ -286,13 +274,6 @@ public class DocumentController {
|
||||
}
|
||||
|
||||
private UUID requireUserId(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
AppUser user = userService.findByEmail(authentication.getName());
|
||||
if (user == null) {
|
||||
throw DomainException.unauthorized("User not found");
|
||||
}
|
||||
return user.getId();
|
||||
return SecurityUtils.requireUserId(authentication, userService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,11 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.raddatz.familienarchiv.dto.CreateTranscriptionBlockDTO;
|
||||
import org.raddatz.familienarchiv.dto.ReorderTranscriptionBlocksDTO;
|
||||
import org.raddatz.familienarchiv.dto.UpdateTranscriptionBlockDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||
import org.raddatz.familienarchiv.security.Permission;
|
||||
import org.raddatz.familienarchiv.security.RequirePermission;
|
||||
import org.raddatz.familienarchiv.security.SecurityUtils;
|
||||
import org.raddatz.familienarchiv.service.TranscriptionService;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
@@ -100,13 +99,6 @@ public class TranscriptionBlockController {
|
||||
}
|
||||
|
||||
private UUID requireUserId(Authentication authentication) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
AppUser user = userService.findByEmail(authentication.getName());
|
||||
if (user == null) {
|
||||
throw DomainException.unauthorized("User not found");
|
||||
}
|
||||
return user.getId();
|
||||
return SecurityUtils.requireUserId(authentication, userService);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.raddatz.familienarchiv.dashboard;
|
||||
|
||||
import org.raddatz.familienarchiv.audit.AuditLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
public interface AuditLogQueryRepository extends JpaRepository<AuditLog, UUID> {
|
||||
|
||||
@Query(value = """
|
||||
SELECT a.document_id
|
||||
FROM audit_log a
|
||||
WHERE a.kind = 'TEXT_SAVED'
|
||||
AND a.actor_id = :userId
|
||||
AND a.document_id IS NOT NULL
|
||||
ORDER BY a.happened_at DESC
|
||||
LIMIT 1
|
||||
""", nativeQuery = true)
|
||||
Optional<UUID> findMostRecentDocumentIdByActor(@Param("userId") UUID userId);
|
||||
|
||||
@Query(value = """
|
||||
SELECT * FROM (
|
||||
SELECT DISTINCT ON (a.actor_id, a.document_id, a.kind, date_trunc('hour', a.happened_at))
|
||||
a.kind AS kind,
|
||||
a.actor_id AS actorId,
|
||||
CASE
|
||||
WHEN u.first_name IS NOT NULL AND u.last_name IS NOT NULL
|
||||
THEN UPPER(LEFT(u.first_name, 1)) || UPPER(LEFT(u.last_name, 1))
|
||||
WHEN u.first_name IS NOT NULL THEN UPPER(LEFT(u.first_name, 1))
|
||||
WHEN u.last_name IS NOT NULL THEN UPPER(LEFT(u.last_name, 1))
|
||||
ELSE '?'
|
||||
END AS actorInitials,
|
||||
COALESCE(u.color, '') AS actorColor,
|
||||
CONCAT_WS(' ', u.first_name, u.last_name) AS actorName,
|
||||
a.document_id AS documentId,
|
||||
a.happened_at AS happenedAt,
|
||||
(a.kind = 'MENTION_CREATED'
|
||||
AND a.payload->>'mentionedUserId' = :currentUserId) AS youMentioned
|
||||
FROM audit_log a
|
||||
LEFT JOIN users u ON u.id = a.actor_id
|
||||
WHERE a.kind IN ('TEXT_SAVED','FILE_UPLOADED','ANNOTATION_CREATED','COMMENT_ADDED','MENTION_CREATED')
|
||||
AND a.document_id IS NOT NULL
|
||||
ORDER BY a.actor_id, a.document_id, a.kind,
|
||||
date_trunc('hour', a.happened_at), a.happened_at DESC
|
||||
) deduped
|
||||
ORDER BY happened_at DESC
|
||||
LIMIT :limit
|
||||
""", nativeQuery = true)
|
||||
List<ActivityFeedRow> findDedupedActivityFeed(
|
||||
@Param("currentUserId") String currentUserId,
|
||||
@Param("limit") int limit);
|
||||
|
||||
@Query(value = """
|
||||
SELECT
|
||||
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber'))) AS pages,
|
||||
COUNT(*) FILTER (WHERE a.kind = 'ANNOTATION_CREATED') AS annotated,
|
||||
COUNT(DISTINCT a.payload->>'blockId') FILTER (WHERE a.kind = 'TEXT_SAVED') AS transcribed,
|
||||
COUNT(DISTINCT a.document_id) FILTER (WHERE a.kind = 'FILE_UPLOADED') AS uploaded,
|
||||
COUNT(DISTINCT (a.document_id::text || '|' || (a.payload->>'pageNumber')))
|
||||
FILTER (WHERE (a.kind = 'ANNOTATION_CREATED' OR a.kind = 'TEXT_SAVED')
|
||||
AND a.actor_id::text = :userId) AS yourPages
|
||||
FROM audit_log a
|
||||
WHERE a.happened_at >= :weekStart
|
||||
AND a.kind IN ('ANNOTATION_CREATED','TEXT_SAVED','FILE_UPLOADED')
|
||||
""", nativeQuery = true)
|
||||
PulseStatsRow getPulseStats(
|
||||
@Param("weekStart") OffsetDateTime weekStart,
|
||||
@Param("userId") String userId);
|
||||
|
||||
@Query(value = """
|
||||
SELECT DISTINCT ON (a.document_id)
|
||||
a.document_id AS documentId,
|
||||
a.actor_id AS actorId
|
||||
FROM audit_log a
|
||||
WHERE a.kind = :kind
|
||||
AND a.document_id IN :documentIds
|
||||
AND a.actor_id IS NOT NULL
|
||||
ORDER BY a.document_id, a.happened_at DESC
|
||||
""", nativeQuery = true)
|
||||
List<Object[]> findMostRecentActorPerDocument(
|
||||
@Param("documentIds") List<UUID> documentIds,
|
||||
@Param("kind") String kind);
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.PostLoad;
|
||||
import jakarta.persistence.PrePersist;
|
||||
import jakarta.persistence.PreUpdate;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
@Data
|
||||
@@ -74,6 +78,28 @@ public class AppUser {
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
@Builder.Default
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private String color = "";
|
||||
|
||||
private static final String[] PALETTE = {
|
||||
"#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8"
|
||||
};
|
||||
|
||||
public static String computeColor(UUID id) {
|
||||
return PALETTE[Math.abs(id.hashCode()) % PALETTE.length];
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
@PreUpdate
|
||||
@PostLoad
|
||||
void deriveColor() {
|
||||
if (id != null && (color == null || color.isEmpty())) {
|
||||
this.color = computeColor(id);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasPermission(String permission) {
|
||||
if (groups == null || groups.isEmpty()) {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.raddatz.familienarchiv.security;
|
||||
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.service.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public final class SecurityUtils {
|
||||
|
||||
private SecurityUtils() {}
|
||||
|
||||
public static UUID requireUserId(Authentication authentication, UserService userService) {
|
||||
if (authentication == null || !authentication.isAuthenticated()) {
|
||||
throw DomainException.unauthorized("Authentication required");
|
||||
}
|
||||
AppUser user = userService.findByEmail(authentication.getName());
|
||||
if (user == null) {
|
||||
throw DomainException.unauthorized("User not found");
|
||||
}
|
||||
return user.getId();
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,8 @@ public class TranscriptionService {
|
||||
if (!text.equals(previousText)) {
|
||||
Optional<DocumentAnnotation> annotation = annotationRepository.findById(block.getAnnotationId());
|
||||
int pageNumber = annotation.map(DocumentAnnotation::getPageNumber).orElse(0);
|
||||
auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId, Map.of("pageNumber", pageNumber));
|
||||
auditService.logAfterCommit(AuditKind.TEXT_SAVED, userId, documentId,
|
||||
Map.of("pageNumber", pageNumber, "blockId", saved.getId().toString()));
|
||||
}
|
||||
|
||||
Document doc = documentService.getDocumentById(documentId);
|
||||
|
||||
@@ -6,7 +6,7 @@ CREATE TABLE audit_log (
|
||||
happened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
-- ON DELETE SET NULL is by design: GDPR right-to-erasure. Deleted users' events
|
||||
-- retain their timestamp and kind but lose actor attribution.
|
||||
actor_id UUID REFERENCES app_users(id) ON DELETE SET NULL,
|
||||
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
kind VARCHAR(50) NOT NULL,
|
||||
document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
|
||||
payload JSONB
|
||||
@@ -19,4 +19,4 @@ CREATE INDEX idx_audit_log_kind ON audit_log (kind);
|
||||
|
||||
-- Enforce append-only at the database layer: the application role may INSERT
|
||||
-- but must not UPDATE or DELETE audit rows.
|
||||
REVOKE UPDATE, DELETE ON audit_log FROM app_user;
|
||||
REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Add deterministic avatar color to app_users.
|
||||
-- Assigned at application layer (AppUser.java) from a fixed 8-colour palette.
|
||||
-- Also corrects V46's REVOKE which hardcoded 'app_user' instead of CURRENT_USER.
|
||||
|
||||
ALTER TABLE users ADD COLUMN color VARCHAR(20) NOT NULL DEFAULT '';
|
||||
|
||||
-- Fix V46 append-only enforcement for the actual application role.
|
||||
REVOKE UPDATE, DELETE ON audit_log FROM CURRENT_USER;
|
||||
@@ -3,7 +3,6 @@ package org.raddatz.familienarchiv.controller;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -390,47 +389,14 @@ class DocumentControllerTest {
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
// ─── GET /api/documents/incomplete (removed — superseded by dashboard) ────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_returns200_withDTOList() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
IncompleteDocumentDTO dto = new IncompleteDocumentDTO(id, "Unvollständig");
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
|
||||
|
||||
void getIncomplete_endpointRemoved() throws Exception {
|
||||
// The path hits /{id} and fails UUID conversion — not a 200 anymore
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Unvollständig"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_withSizeParam_passesItToService() throws Exception {
|
||||
when(documentService.findIncompleteDocuments(5)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete").param("size", "5"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIncompleteDocuments(5);
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_usesDefaultSizeWhenNotSpecified() throws Exception {
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIncompleteDocuments(10);
|
||||
.andExpect(status().is4xxClientError());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||
@@ -467,36 +433,14 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/recent-activity ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getRecentActivity_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/recent-activity"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
// ─── GET /api/documents/recent-activity (removed — superseded by dashboard)
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getRecentActivity_returnsOkWithDocuments() throws Exception {
|
||||
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||
when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2));
|
||||
|
||||
mockMvc.perform(get("/api/documents/recent-activity").param("size", "5"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].title").value("Alpha"))
|
||||
.andExpect(jsonPath("$[1].title").value("Beta"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception {
|
||||
when(documentService.getRecentActivity(5)).thenReturn(List.of());
|
||||
|
||||
void getRecentActivity_endpointRemoved() throws Exception {
|
||||
// The path hits /{id} and fails UUID conversion — not a 200 anymore
|
||||
mockMvc.perform(get("/api/documents/recent-activity"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).getRecentActivity(5);
|
||||
.andExpect(status().is4xxClientError());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.raddatz.familienarchiv.model;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AppUserTest {
|
||||
|
||||
private static final List<String> EXPECTED_PALETTE = List.of(
|
||||
"#7a4f9a", "#5a8a6a", "#3060b0", "#a0522d", "#c0446e", "#c17a00", "#0e7490", "#1d4ed8"
|
||||
);
|
||||
|
||||
@Test
|
||||
void computeColor_returnsDeterministicPaletteColor() {
|
||||
UUID id = UUID.fromString("12345678-1234-1234-1234-123456789abc");
|
||||
String color = AppUser.computeColor(id);
|
||||
assertThat(EXPECTED_PALETTE).contains(color);
|
||||
assertThat(AppUser.computeColor(id)).isEqualTo(color);
|
||||
}
|
||||
|
||||
@Test
|
||||
void computeColor_isStableAcrossCalls() {
|
||||
UUID id = UUID.randomUUID();
|
||||
assertThat(AppUser.computeColor(id)).isEqualTo(AppUser.computeColor(id));
|
||||
}
|
||||
|
||||
@Test
|
||||
void computeColor_variesAcrossDifferentIds() {
|
||||
long distinct = java.util.stream.IntStream.range(0, 100)
|
||||
.mapToObj(i -> AppUser.computeColor(UUID.randomUUID()))
|
||||
.distinct()
|
||||
.count();
|
||||
assertThat(distinct).isGreaterThan(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package org.raddatz.familienarchiv.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
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.service.UserService;
|
||||
import org.springframework.security.core.Authentication;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SecurityUtilsTest {
|
||||
|
||||
@Mock Authentication authentication;
|
||||
@Mock UserService userService;
|
||||
|
||||
@Test
|
||||
void requireUserId_throwsUnauthorized_whenAuthenticationIsNull() {
|
||||
assertThatThrownBy(() -> SecurityUtils.requireUserId(null, userService))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserId_throwsUnauthorized_whenNotAuthenticated() {
|
||||
when(authentication.isAuthenticated()).thenReturn(false);
|
||||
assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserId_throwsUnauthorized_whenUserNotFound() {
|
||||
when(authentication.isAuthenticated()).thenReturn(true);
|
||||
when(authentication.getName()).thenReturn("ghost@example.com");
|
||||
when(userService.findByEmail("ghost@example.com")).thenReturn(null);
|
||||
assertThatThrownBy(() -> SecurityUtils.requireUserId(authentication, userService))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requireUserId_returnsUserId_whenAuthenticated() {
|
||||
UUID userId = UUID.randomUUID();
|
||||
AppUser user = AppUser.builder().id(userId).email("user@example.com").password("pw").build();
|
||||
when(authentication.isAuthenticated()).thenReturn(true);
|
||||
when(authentication.getName()).thenReturn("user@example.com");
|
||||
when(userService.findByEmail("user@example.com")).thenReturn(user);
|
||||
|
||||
UUID result = SecurityUtils.requireUserId(authentication, userService);
|
||||
|
||||
assertThat(result).isEqualTo(userId);
|
||||
}
|
||||
}
|
||||
@@ -487,6 +487,7 @@ class TranscriptionServiceTest {
|
||||
org.mockito.ArgumentMatchers.eq(docId),
|
||||
payloadCaptor.capture());
|
||||
assertThat(payloadCaptor.getValue()).containsEntry("pageNumber", 3);
|
||||
assertThat(payloadCaptor.getValue()).containsEntry("blockId", blockId.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -706,5 +706,46 @@
|
||||
"admin_new_invite_expires": "Ablaufdatum (optional)",
|
||||
"admin_invite_created_title": "Einladung erstellt",
|
||||
"admin_invite_created_desc": "Teile diesen Link mit der einzuladenden Person:",
|
||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?"
|
||||
"admin_invite_revoke_confirm": "Einladung wirklich widerrufen?",
|
||||
|
||||
"greeting_morning": "Guten Morgen, {name}.",
|
||||
"greeting_day": "Hallo, {name}.",
|
||||
"greeting_evening": "Guten Abend, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Weiter, wo du aufgehört hast",
|
||||
"dashboard_page_of": "Seite {page} von {pages}",
|
||||
"dashboard_resume_cta": "Weitertranskribieren",
|
||||
"dashboard_resume_other": "oder anderen Brief wählen",
|
||||
"dashboard_empty_title": "Noch kein Dokument begonnen",
|
||||
"dashboard_empty_body": "Wähle ein Dokument aus dem Archiv, um mit der Transkription zu beginnen.",
|
||||
"dashboard_empty_cta": "Zum Archiv",
|
||||
|
||||
"dashboard_mission_caption": "Offene Aufgaben",
|
||||
"queue_segment": "Segmentieren",
|
||||
"queue_segment_blurb": "Seiten aufteilen",
|
||||
"queue_transcribe": "Transkribieren",
|
||||
"queue_transcribe_blurb": "Text erfassen",
|
||||
"queue_review": "Prüfen",
|
||||
"queue_review_blurb": "Texte kontrollieren",
|
||||
"queue_n_open": "{n} offen",
|
||||
"queue_show_all": "Alle anzeigen →",
|
||||
|
||||
"pulse_eyebrow": "Diese Woche",
|
||||
"pulse_headline": "Ihr habt {pages} Seiten bearbeitet.",
|
||||
"pulse_you": "Du selbst hast {pages} davon bearbeitet.",
|
||||
"pulse_contributors": "Mitwirkende",
|
||||
"pulse_transcribed": "Textstellen markiert",
|
||||
"pulse_reviewed": "Textstellen transkribiert",
|
||||
"pulse_uploaded": "Dokumente hochgeladen",
|
||||
|
||||
"feed_caption": "Kommentare & Aktivität",
|
||||
"feed_for_you": "für dich",
|
||||
|
||||
"audit_action_text_saved": "hat Text gespeichert in",
|
||||
"audit_action_file_uploaded": "hat eine Datei hochgeladen:",
|
||||
"audit_action_annotation_created": "hat eine Markierung erstellt in",
|
||||
"audit_action_comment_added": "hat kommentiert:",
|
||||
"audit_action_mention_created": "hat dich erwähnt in",
|
||||
|
||||
"dropzone_release": "Loslassen zum Hochladen"
|
||||
}
|
||||
|
||||
@@ -706,5 +706,46 @@
|
||||
"admin_new_invite_expires": "Expiry date (optional)",
|
||||
"admin_invite_created_title": "Invite created",
|
||||
"admin_invite_created_desc": "Share this link with the person you are inviting:",
|
||||
"admin_invite_revoke_confirm": "Really revoke this invite?"
|
||||
"admin_invite_revoke_confirm": "Really revoke this invite?",
|
||||
|
||||
"greeting_morning": "Good morning, {name}.",
|
||||
"greeting_day": "Hello, {name}.",
|
||||
"greeting_evening": "Good evening, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continue where you left off",
|
||||
"dashboard_page_of": "Page {page} of {pages}",
|
||||
"dashboard_resume_cta": "Continue transcribing",
|
||||
"dashboard_resume_other": "or choose another document",
|
||||
"dashboard_empty_title": "No document started yet",
|
||||
"dashboard_empty_body": "Choose a document from the archive to start transcribing.",
|
||||
"dashboard_empty_cta": "To the archive",
|
||||
|
||||
"dashboard_mission_caption": "Open tasks",
|
||||
"queue_segment": "Segment",
|
||||
"queue_segment_blurb": "Split pages",
|
||||
"queue_transcribe": "Transcribe",
|
||||
"queue_transcribe_blurb": "Capture text",
|
||||
"queue_review": "Review",
|
||||
"queue_review_blurb": "Check texts",
|
||||
"queue_n_open": "{n} open",
|
||||
"queue_show_all": "Show all →",
|
||||
|
||||
"pulse_eyebrow": "This week",
|
||||
"pulse_headline": "You have worked on {pages} pages.",
|
||||
"pulse_you": "You personally worked on {pages} of them.",
|
||||
"pulse_contributors": "Contributors",
|
||||
"pulse_transcribed": "Passages annotated",
|
||||
"pulse_reviewed": "Passages transcribed",
|
||||
"pulse_uploaded": "Documents uploaded",
|
||||
|
||||
"feed_caption": "Comments & activity",
|
||||
"feed_for_you": "for you",
|
||||
|
||||
"audit_action_text_saved": "saved text in",
|
||||
"audit_action_file_uploaded": "uploaded a file:",
|
||||
"audit_action_annotation_created": "created an annotation in",
|
||||
"audit_action_comment_added": "commented:",
|
||||
"audit_action_mention_created": "mentioned you in",
|
||||
|
||||
"dropzone_release": "Release to upload"
|
||||
}
|
||||
|
||||
@@ -706,5 +706,46 @@
|
||||
"admin_new_invite_expires": "Fecha de vencimiento (opcional)",
|
||||
"admin_invite_created_title": "Invitación creada",
|
||||
"admin_invite_created_desc": "Comparte este enlace con la persona invitada:",
|
||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?"
|
||||
"admin_invite_revoke_confirm": "¿Realmente revocar esta invitación?",
|
||||
|
||||
"greeting_morning": "Buenos días, {name}.",
|
||||
"greeting_day": "Hola, {name}.",
|
||||
"greeting_evening": "Buenas noches, {name}.",
|
||||
|
||||
"dashboard_resume_label": "Continuar donde lo dejaste",
|
||||
"dashboard_page_of": "Página {page} de {pages}",
|
||||
"dashboard_resume_cta": "Continuar transcripción",
|
||||
"dashboard_resume_other": "o elige otro documento",
|
||||
"dashboard_empty_title": "Aún no has comenzado ningún documento",
|
||||
"dashboard_empty_body": "Elige un documento del archivo para empezar a transcribir.",
|
||||
"dashboard_empty_cta": "Al archivo",
|
||||
|
||||
"dashboard_mission_caption": "Tareas pendientes",
|
||||
"queue_segment": "Segmentar",
|
||||
"queue_segment_blurb": "Dividir páginas",
|
||||
"queue_transcribe": "Transcribir",
|
||||
"queue_transcribe_blurb": "Capturar texto",
|
||||
"queue_review": "Revisar",
|
||||
"queue_review_blurb": "Controlar textos",
|
||||
"queue_n_open": "{n} pendiente",
|
||||
"queue_show_all": "Ver todo →",
|
||||
|
||||
"pulse_eyebrow": "Esta semana",
|
||||
"pulse_headline": "Habéis trabajado {pages} páginas.",
|
||||
"pulse_you": "Tú mismo has trabajado {pages} de ellas.",
|
||||
"pulse_contributors": "Colaboradores",
|
||||
"pulse_transcribed": "Fragmentos anotados",
|
||||
"pulse_reviewed": "Fragmentos transcritos",
|
||||
"pulse_uploaded": "Documentos subidos",
|
||||
|
||||
"feed_caption": "Comentarios y actividad",
|
||||
"feed_for_you": "para ti",
|
||||
|
||||
"audit_action_text_saved": "guardó texto en",
|
||||
"audit_action_file_uploaded": "subió un archivo:",
|
||||
"audit_action_annotation_created": "creó una anotación en",
|
||||
"audit_action_comment_added": "comentó:",
|
||||
"audit_action_mention_created": "te mencionó en",
|
||||
|
||||
"dropzone_release": "Suelta para subir"
|
||||
}
|
||||
|
||||
@@ -324,6 +324,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/invites": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["listInvites"];
|
||||
put?: never;
|
||||
post: operations["createInvite"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/groups": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -356,6 +372,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/file": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentFile"];
|
||||
put?: never;
|
||||
post: operations["attachFile"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/transcription-blocks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -532,6 +564,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/register": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post: operations["register"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/forgot-password": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1044,22 +1092,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{id}/file": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getDocumentFile"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/{documentId}/transcription-blocks/{blockId}/history": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1108,38 +1140,6 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/recent-activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getRecentActivity"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getIncomplete"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete/next": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1188,6 +1188,70 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/dashboard/resume": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getResume"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/dashboard/pulse": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getPulse"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/dashboard/activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getActivity"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/auth/invite/{code}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getInvitePrefill"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/admin/import-status": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1236,6 +1300,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/invites/{id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete: operations["revokeInvite"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -1253,12 +1333,13 @@ export interface components {
|
||||
AppUser: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** Format: email */
|
||||
email: string;
|
||||
password?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: date */
|
||||
birthDate?: string;
|
||||
email: string;
|
||||
contact?: string;
|
||||
enabled: boolean;
|
||||
notifyOnReply: boolean;
|
||||
@@ -1266,6 +1347,7 @@ export interface components {
|
||||
groups: components["schemas"]["UserGroup"][];
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
color: string;
|
||||
};
|
||||
UserGroup: {
|
||||
/** Format: uuid */
|
||||
@@ -1405,6 +1487,7 @@ export interface components {
|
||||
blockIds?: string[];
|
||||
};
|
||||
CreateUserRequest: {
|
||||
/** Format: email */
|
||||
email: string;
|
||||
initialPassword?: string;
|
||||
groupIds?: string[];
|
||||
@@ -1470,11 +1553,40 @@ export interface components {
|
||||
};
|
||||
TriggerSenderTrainingDTO: {
|
||||
/** Format: uuid */
|
||||
personId?: string;
|
||||
personId: string;
|
||||
};
|
||||
BatchOcrDTO: {
|
||||
documentIds: string[];
|
||||
};
|
||||
CreateInviteRequest: {
|
||||
label?: string;
|
||||
/** Format: int32 */
|
||||
maxUses?: number;
|
||||
prefillFirstName?: string;
|
||||
prefillLastName?: string;
|
||||
prefillEmail?: string;
|
||||
groupIds?: string[];
|
||||
/** Format: date-time */
|
||||
expiresAt?: string;
|
||||
};
|
||||
InviteListItemDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
code: string;
|
||||
displayCode: string;
|
||||
label?: string;
|
||||
/** Format: int32 */
|
||||
useCount: number;
|
||||
/** Format: int32 */
|
||||
maxUses?: number;
|
||||
/** Format: date-time */
|
||||
expiresAt?: string;
|
||||
revoked: boolean;
|
||||
status: string;
|
||||
/** Format: date-time */
|
||||
createdAt: string;
|
||||
shareableUrl?: string;
|
||||
};
|
||||
GroupDTO: {
|
||||
name?: string;
|
||||
permissions?: string[];
|
||||
@@ -1580,6 +1692,15 @@ export interface components {
|
||||
token?: string;
|
||||
newPassword?: string;
|
||||
};
|
||||
RegisterRequest: {
|
||||
code: string;
|
||||
/** Format: email */
|
||||
email: string;
|
||||
password: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
notifyOnMention?: boolean;
|
||||
};
|
||||
ForgotPasswordRequest: {
|
||||
email?: string;
|
||||
};
|
||||
@@ -1754,6 +1875,8 @@ export interface components {
|
||||
/** Format: int64 */
|
||||
totalElements?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
/** Format: int32 */
|
||||
size?: number;
|
||||
content?: components["schemas"]["NotificationDTO"][];
|
||||
@@ -1762,8 +1885,6 @@ export interface components {
|
||||
sort?: components["schemas"]["SortObject"];
|
||||
/** Format: int32 */
|
||||
numberOfElements?: number;
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
empty?: boolean;
|
||||
};
|
||||
PageableObject: {
|
||||
@@ -1847,10 +1968,54 @@ export interface components {
|
||||
summarySnippet?: string;
|
||||
summaryOffsets: components["schemas"]["MatchOffset"][];
|
||||
};
|
||||
IncompleteDocumentDTO: {
|
||||
ActivityActorDTO: {
|
||||
initials: string;
|
||||
color: string;
|
||||
name?: string;
|
||||
};
|
||||
DashboardResumeDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
documentId: string;
|
||||
title: string;
|
||||
caption: string;
|
||||
excerpt: string;
|
||||
/** Format: int32 */
|
||||
page: number;
|
||||
/** Format: int32 */
|
||||
pages: number;
|
||||
/** Format: int32 */
|
||||
pct: number;
|
||||
thumbnailUrl?: string;
|
||||
collaborators: components["schemas"]["ActivityActorDTO"][];
|
||||
};
|
||||
DashboardPulseDTO: {
|
||||
/** Format: int32 */
|
||||
pages: number;
|
||||
/** Format: int32 */
|
||||
annotated: number;
|
||||
/** Format: int32 */
|
||||
transcribed: number;
|
||||
/** Format: int32 */
|
||||
uploaded: number;
|
||||
/** Format: int32 */
|
||||
yourPages: number;
|
||||
contributors: components["schemas"]["ActivityActorDTO"][];
|
||||
};
|
||||
ActivityFeedItemDTO: {
|
||||
/** @enum {string} */
|
||||
kind: "FILE_UPLOADED" | "STATUS_CHANGED" | "METADATA_UPDATED" | "TEXT_SAVED" | "BLOCK_REVIEWED" | "ANNOTATION_CREATED" | "COMMENT_ADDED" | "MENTION_CREATED";
|
||||
actor?: components["schemas"]["ActivityActorDTO"];
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
documentTitle: string;
|
||||
/** Format: date-time */
|
||||
happenedAt: string;
|
||||
youMentioned: boolean;
|
||||
};
|
||||
InvitePrefillDTO: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
@@ -2619,6 +2784,52 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
listInvites: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["InviteListItemDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
createInvite: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["CreateInviteRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["InviteListItemDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getAllGroups: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2687,6 +2898,57 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentFile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
attachFile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
/** Format: binary */
|
||||
file: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
listBlocks: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3086,6 +3348,30 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
register: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["RegisterRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["AppUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
forgotPassword: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3603,9 +3889,7 @@ export interface operations {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
"*/*": components["schemas"]["TrainingInfoResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -3850,28 +4134,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getDocumentFile: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getBlockHistory: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -3953,51 +4215,6 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getRecentActivity: {
|
||||
parameters: {
|
||||
query?: {
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncomplete: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Maximum number of results */
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["IncompleteDocumentDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getNextIncomplete: {
|
||||
parameters: {
|
||||
query: {
|
||||
@@ -4068,6 +4285,90 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getResume: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DashboardResumeDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getPulse: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["DashboardPulseDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getActivity: {
|
||||
parameters: {
|
||||
query?: {
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["ActivityFeedItemDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getInvitePrefill: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
code: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["InvitePrefillDTO"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
importStatus: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -4129,4 +4430,24 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
revokeInvite: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,16 +15,6 @@ const unsaved = createUnsavedWarning();
|
||||
|
||||
const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
|
||||
|
||||
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||
|
||||
async function handleDelete() {
|
||||
const confirmed = await confirm({
|
||||
title: m.admin_user_delete_confirm({ username: data.editUser.email }),
|
||||
destructive: true
|
||||
});
|
||||
if (confirmed) deleteFormEl!.requestSubmit();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (form?.success) unsaved.clearOnSuccess();
|
||||
});
|
||||
@@ -51,10 +41,23 @@ $effect(() => {
|
||||
<h2 class="flex-1 font-sans text-sm font-bold text-ink">
|
||||
{m.admin_user_edit_heading({ username: data.editUser.email })}
|
||||
</h2>
|
||||
<form bind:this={deleteFormEl} method="POST" action="?/delete" use:enhance>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={async ({ cancel }) => {
|
||||
const confirmed = await confirm({
|
||||
title: m.admin_user_delete_confirm({ username: data.editUser.email }),
|
||||
destructive: true
|
||||
});
|
||||
if (!confirmed) {
|
||||
cancel();
|
||||
} else {
|
||||
unsaved.clearOnSuccess();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
type="submit"
|
||||
class="rounded-sm border border-red-200 bg-red-50 px-3 py-1 font-sans text-xs font-bold tracking-widest text-red-700 uppercase transition-colors hover:bg-red-100 dark:border-red-900 dark:bg-red-950/30 dark:text-red-400"
|
||||
>
|
||||
{m.btn_delete()}…
|
||||
|
||||
@@ -4,7 +4,17 @@ import { page } from 'vitest/browser';
|
||||
import Page from './+page.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
const cancelMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('$app/forms', () => ({
|
||||
enhance: (form: HTMLFormElement, callback?: (args: { cancel: () => void }) => Promise<void>) => {
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (callback) await callback({ cancel: cancelMock });
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
}));
|
||||
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
||||
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
@@ -161,39 +171,39 @@ describe('Admin edit user page – feedback', () => {
|
||||
// ─── Delete confirmation ──────────────────────────────────────────────────────
|
||||
|
||||
describe('Admin edit user page – delete confirmation', () => {
|
||||
it('delete button has type=button (does not submit natively)', async () => {
|
||||
beforeEach(() => cancelMock.mockClear());
|
||||
|
||||
it('delete button has type=submit', async () => {
|
||||
renderPage({ data: baseData, form: null });
|
||||
const deleteForm = document.querySelector<HTMLFormElement>('form[action="?/delete"]')!;
|
||||
const deleteBtn = deleteForm.querySelector('button') as HTMLButtonElement;
|
||||
expect(deleteBtn.type).toBe('button');
|
||||
expect(deleteBtn.type).toBe('submit');
|
||||
});
|
||||
|
||||
it('does not submit delete form when user cancels', async () => {
|
||||
it('calls cancel() and does not submit when user cancels', async () => {
|
||||
const { service } = renderPage({ data: baseData, form: null });
|
||||
const deleteForm = document.querySelector<HTMLFormElement>('form[action="?/delete"]')!;
|
||||
const requestSubmit = vi.spyOn(deleteForm, 'requestSubmit').mockImplementation(() => {});
|
||||
const deleteBtn = deleteForm.querySelector('button') as HTMLButtonElement;
|
||||
|
||||
const deleteBtn = deleteForm.querySelector('button[type="button"]') as HTMLButtonElement;
|
||||
deleteBtn.click();
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull());
|
||||
service.settle(false);
|
||||
await vi.waitFor(() => expect(service.options).toBeNull());
|
||||
|
||||
expect(requestSubmit).not.toHaveBeenCalled();
|
||||
expect(cancelMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('submits delete form when user confirms', async () => {
|
||||
it('does not call cancel() and allows submit when user confirms', async () => {
|
||||
const { service } = renderPage({ data: baseData, form: null });
|
||||
const deleteForm = document.querySelector<HTMLFormElement>('form[action="?/delete"]')!;
|
||||
const requestSubmit = vi.spyOn(deleteForm, 'requestSubmit').mockImplementation(() => {});
|
||||
const deleteBtn = deleteForm.querySelector('button') as HTMLButtonElement;
|
||||
|
||||
const deleteBtn = deleteForm.querySelector('button[type="button"]') as HTMLButtonElement;
|
||||
deleteBtn.click();
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull());
|
||||
service.settle(true);
|
||||
await vi.waitFor(() => expect(service.options).toBeNull());
|
||||
|
||||
expect(requestSubmit).toHaveBeenCalledOnce();
|
||||
expect(cancelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user