package id.iptek.utms.preference.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import id.iptek.utms.auth.security.UserPrincipal; import id.iptek.utms.core.exception.AppException; import id.iptek.utms.core.i18n.MessageResolver; import id.iptek.utms.preference.domain.UserUiPreference; import id.iptek.utms.preference.dto.TablePreferenceProfile; import id.iptek.utms.preference.dto.TablePreferenceRequest; import id.iptek.utms.preference.dto.TablePreferenceSavedProfile; import id.iptek.utms.preference.dto.UserUiPreferencesResponse; import id.iptek.utms.preference.repository.UserUiPreferenceRepository; import id.iptek.utms.tenant.TenantContext; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.regex.Pattern; @Service public class UserPreferenceService { private static final String VALUE_JSON_FIELD = "visibleColumns"; private static final Pattern PREFERENCE_KEY_PATTERN = Pattern.compile("^(users|roles|workflow|audit|modules):[A-Za-z0-9_./-]+$"); private static final Map> DEFAULT_COLUMNS_BY_KEY = Map.of( "users:workflow-requests", List.of("id", "resourceType", "resourceId", "makerUsername", "status", "requiredSteps", "currentStep", "createdAt", "updatedAt", "actions"), "workflow:requests", List.of("id", "resourceType", "resourceId", "makerUsername", "status", "updatedAt") ); private final UserUiPreferenceRepository repository; private final ObjectMapper objectMapper; private final MessageResolver messageResolver; public UserPreferenceService(UserUiPreferenceRepository repository, ObjectMapper objectMapper, MessageResolver messageResolver) { this.repository = repository; this.objectMapper = objectMapper; this.messageResolver = messageResolver; } public UserUiPreferencesResponse getAll(Authentication authentication) { UUID userId = getUserId(authentication); TenantContext.getRequiredTenantId(); List preferences = repository.findByUserId(userId); preferences.sort(Comparator.comparing(UserUiPreference::getUpdatedAt, Comparator.nullsLast(Comparator.reverseOrder()))); List columns = preferences.stream() .map(this::toProfile) .toList(); Instant latestUpdatedAt = preferences.stream() .map(UserUiPreference::getUpdatedAt) .filter(Objects::nonNull) .max(Instant::compareTo) .orElse(null); return new UserUiPreferencesResponse(columns, latestUpdatedAt); } @Transactional public TablePreferenceSavedProfile upsert(Authentication authentication, TablePreferenceRequest request) { UUID userId = getUserId(authentication); String tenantId = TenantContext.getRequiredTenantId(); String normalizedKey = normalizePreferenceKey(request.preferenceKey()); List normalizedColumns = normalizeVisibleColumns(request.visibleColumns()); UserUiPreference preference = repository.findByUserIdAndPreferenceKey(userId, normalizedKey) .orElseGet(() -> { UserUiPreference created = new UserUiPreference(); created.setUserId(userId); created.setTenantId(tenantId); created.setPreferenceKey(normalizedKey); return created; }); preference.setValueJson(serializePreferenceValue(normalizedColumns)); UserUiPreference saved = repository.save(preference); return new TablePreferenceSavedProfile( saved.getPreferenceKey(), normalizedColumns, saved.getUpdatedAt() ); } @Transactional public TablePreferenceProfile resetTablePreference(Authentication authentication, String preferenceKey) { UUID userId = getUserId(authentication); String normalizedKey = normalizePreferenceKey(preferenceKey); repository.deleteByUserIdAndPreferenceKey(userId, normalizedKey); return new TablePreferenceProfile(normalizedKey, getDefaultColumns(normalizedKey)); } @Transactional public void resetAll(Authentication authentication) { UUID userId = getUserId(authentication); repository.deleteByUserId(userId); } private TablePreferenceProfile toProfile(UserUiPreference preference) { return new TablePreferenceProfile( preference.getPreferenceKey(), parseVisibleColumns(preference.getValueJson()) ); } private UUID getUserId(Authentication authentication) { Object principal = authentication != null ? authentication.getPrincipal() : null; if (principal instanceof UserPrincipal userPrincipal) { return userPrincipal.getId(); } throw new AppException(messageResolver.get("auth.invalid.credentials")); } private String normalizePreferenceKey(String preferenceKey) { if (preferenceKey == null || preferenceKey.isBlank()) { throw new AppException(messageResolver.get("user.preferences.invalid.key")); } String normalized = preferenceKey.trim(); if (!PREFERENCE_KEY_PATTERN.matcher(normalized).matches()) { throw new AppException(messageResolver.get("user.preferences.invalid.key")); } return normalized; } private List normalizeVisibleColumns(List visibleColumns) { if (visibleColumns == null || visibleColumns.isEmpty()) { throw new AppException(messageResolver.get("user.preferences.invalid.columns")); } List normalized = visibleColumns.stream() .map(column -> column == null ? null : column.trim()) .filter(Objects::nonNull) .toList(); if (normalized.isEmpty() || normalized.stream().anyMatch(String::isBlank)) { throw new AppException(messageResolver.get("user.preferences.invalid.columns")); } return List.copyOf(normalized); } private String serializePreferenceValue(List visibleColumns) { try { Map> value = new LinkedHashMap<>(); value.put(VALUE_JSON_FIELD, visibleColumns); return objectMapper.writeValueAsString(value); } catch (Exception ex) { throw new AppException(messageResolver.get("user.preferences.serialize.failed")); } } private List parseVisibleColumns(String valueJson) { try { JsonNode root = objectMapper.readTree(valueJson); JsonNode columns = root.get(VALUE_JSON_FIELD); if (columns == null || !columns.isArray()) { throw new AppException(messageResolver.get("user.preferences.invalid.value")); } List visibleColumns = new ArrayList<>(); for (JsonNode value : columns) { String column = value != null ? value.asText() : null; if (column == null || column.isBlank()) { throw new AppException(messageResolver.get("user.preferences.invalid.value")); } visibleColumns.add(column); } return visibleColumns; } catch (AppException ex) { throw ex; } catch (Exception ex) { throw new AppException(messageResolver.get("user.preferences.invalid.value")); } } private List getDefaultColumns(String preferenceKey) { return DEFAULT_COLUMNS_BY_KEY.getOrDefault(preferenceKey, List.of("id", "createdAt", "updatedAt", "actions")); } }