Compare commits

...

8 Commits

Author SHA1 Message Date
Marcel
5b15991cf3 fix(admin): wire delete-user button via enhance callback instead of requestSubmit()
Some checks failed
CI / OCR Service Tests (push) Successful in 30s
CI / Backend Unit Tests (push) Failing after 1m22s
CI / Unit & Component Tests (pull_request) Failing after 2m28s
CI / OCR Service Tests (pull_request) Successful in 30s
CI / Unit & Component Tests (push) Failing after 2m26s
CI / Backend Unit Tests (pull_request) Failing after 1m23s
The delete button used type=button + requestSubmit() to trigger the form,
which did not reliably fire SvelteKit's enhance submit listener. Replaced
with a type=submit button and an async enhance callback that guards with
the confirm dialog and calls cancel() on rejection.

Also clears the unsaved-changes dirty flag before the redirect so
beforeNavigate doesn't silently block the post-delete navigation.

Closes #277

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 18:07:06 +02:00
Marcel
99247ed58d feat(i18n): add dashboard i18n keys (de/en/es)
Greeting, resume card, mission control, family pulse, activity feed,
audit action verbs, and dropzone keys for the Issue #271 dashboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:13:57 +02:00
Marcel
714f00ef9d chore(types): regenerate API types with dashboard endpoints
Adds DashboardResumeDTO, DashboardPulseDTO, ActivityFeedItemDTO,
ActivityActorDTO and the three /api/dashboard/* paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:10:50 +02:00
Marcel
9e0b72bc10 feat(dashboard): remove deprecated /incomplete and /recent-activity endpoints
GET /api/documents/incomplete and GET /api/documents/recent-activity are
superseded by the new dashboard endpoints (GET /api/dashboard/activity etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:05:14 +02:00
Marcel
c678432d25 fix(migration): correct app_users → users table references in V46/V47
The AppUser entity is mapped to the 'users' table (not 'app_users').
V46 had a broken REFERENCES clause and hardcoded role in REVOKE; V47 and the
native query in AuditLogQueryRepository had the same wrong table name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:58:04 +02:00
Marcel
19832dc1e0 refactor(security): extract requireUserId to SecurityUtils
Both DocumentController and TranscriptionBlockController contained
identical private requireUserId helpers. Extracted to a shared static
utility in the security package ahead of DashboardController which
also needs actor resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:39:41 +02:00
Marcel
b3013c42c0 fix(audit): add blockId to TEXT_SAVED audit payload
Required for dashboard Pulse stat 2 (COUNT DISTINCT blockId).
Without it, two saves on different blocks on the same page
were indistinguishable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:36:02 +02:00
Marcel
cb02dc84f6 feat(user): add deterministic avatar color to AppUser
Adds color field assigned from an 8-colour palette keyed on the user's UUID
hash (Math.abs(id.hashCode()) % 8). Fires via @PrePersist/@PreUpdate/@PostLoad
so both new and existing users get the correct colour at runtime.

V47 migration adds the column and fixes the V46 REVOKE bug that hardcoded
role name 'app_user' instead of CURRENT_USER.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 16:33:27 +02:00
18 changed files with 867 additions and 249 deletions

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 ────────────────────────────────────

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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;
};
};
};
}

View File

@@ -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()}

View File

@@ -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();
});
});