diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java index 3ac33625..6b2b8419 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/AppUser.java @@ -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; diff --git a/backend/src/main/resources/db/migration/V47__add_user_color.sql b/backend/src/main/resources/db/migration/V47__add_user_color.sql new file mode 100644 index 00000000..cca60905 --- /dev/null +++ b/backend/src/main/resources/db/migration/V47__add_user_color.sql @@ -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 app_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; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java b/backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java new file mode 100644 index 00000000..8fec24fd --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/model/AppUserTest.java @@ -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 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); + } +}