Harden access control and restore customer admin pages
This commit is contained in:
@@ -27,6 +27,7 @@ public record Sample(
|
||||
LocalDateTime createdAt,
|
||||
LocalDateTime updatedAt,
|
||||
LocalDateTime completedAt,
|
||||
String ownerAccountId,
|
||||
String createdByUserCode,
|
||||
String createdByDisplayName
|
||||
) {
|
||||
|
||||
@@ -22,6 +22,9 @@ public class AuthTokenService {
|
||||
@Value("${muh.security.token-secret}") String secret,
|
||||
@Value("${muh.security.token-validity-hours:12}") long validityHours
|
||||
) {
|
||||
if (secret == null || secret.isBlank()) {
|
||||
throw new IllegalStateException("MUH_TOKEN_SECRET muss gesetzt sein");
|
||||
}
|
||||
this.secret = secret.getBytes(StandardCharsets.UTF_8);
|
||||
this.validitySeconds = Math.max(1, validityHours) * 3600L;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.svencarstensen.muh.security;
|
||||
|
||||
import de.svencarstensen.muh.domain.AppUser;
|
||||
import de.svencarstensen.muh.domain.UserRole;
|
||||
import de.svencarstensen.muh.repository.AppUserRepository;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
public class AuthorizationService {
|
||||
|
||||
private final AppUserRepository appUserRepository;
|
||||
|
||||
public AuthorizationService(AppUserRepository appUserRepository) {
|
||||
this.appUserRepository = appUserRepository;
|
||||
}
|
||||
|
||||
public AppUser requireActiveUser(String actorId, String message) {
|
||||
return appUserRepository.findById(requireText(actorId, message))
|
||||
.filter(AppUser::active)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN, message));
|
||||
}
|
||||
|
||||
public void requireAdmin(String actorId, String message) {
|
||||
AppUser actor = requireActiveUser(actorId, message);
|
||||
if (!isAdmin(actor)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, message);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isAdmin(AppUser user) {
|
||||
return user.role() == UserRole.ADMIN;
|
||||
}
|
||||
|
||||
public String accountId(AppUser user) {
|
||||
if (user.accountId() == null || user.accountId().isBlank()) {
|
||||
if (user.id() == null || user.id().isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Benutzerkonto ungueltig");
|
||||
}
|
||||
return user.id().trim();
|
||||
}
|
||||
return user.accountId().trim();
|
||||
}
|
||||
|
||||
private String requireText(String value, String message) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import de.svencarstensen.muh.repository.FarmerRepository;
|
||||
import de.svencarstensen.muh.repository.MedicationCatalogRepository;
|
||||
import de.svencarstensen.muh.repository.PathogenCatalogRepository;
|
||||
import de.svencarstensen.muh.security.AuthTokenService;
|
||||
import de.svencarstensen.muh.security.AuthorizationService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||
@@ -66,6 +67,7 @@ public class CatalogService {
|
||||
private final AppUserRepository appUserRepository;
|
||||
private final MongoTemplate mongoTemplate;
|
||||
private final AuthTokenService authTokenService;
|
||||
private final AuthorizationService authorizationService;
|
||||
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
|
||||
public CatalogService(
|
||||
@@ -75,7 +77,8 @@ public class CatalogService {
|
||||
AntibioticCatalogRepository antibioticRepository,
|
||||
AppUserRepository appUserRepository,
|
||||
MongoTemplate mongoTemplate,
|
||||
AuthTokenService authTokenService
|
||||
AuthTokenService authTokenService,
|
||||
AuthorizationService authorizationService
|
||||
) {
|
||||
this.farmerRepository = farmerRepository;
|
||||
this.medicationRepository = medicationRepository;
|
||||
@@ -84,6 +87,7 @@ public class CatalogService {
|
||||
this.appUserRepository = appUserRepository;
|
||||
this.mongoTemplate = mongoTemplate;
|
||||
this.authTokenService = authTokenService;
|
||||
this.authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
public ActiveCatalogSummary activeCatalogSummary() {
|
||||
@@ -96,7 +100,8 @@ public class CatalogService {
|
||||
);
|
||||
}
|
||||
|
||||
public AdministrationOverview administrationOverview() {
|
||||
public AdministrationOverview administrationOverview(String actorId) {
|
||||
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
|
||||
return new AdministrationOverview(listFarmerRows(), listMedicationRows(), listPathogenRows(), listAntibioticRows());
|
||||
}
|
||||
|
||||
@@ -128,7 +133,8 @@ public class CatalogService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<FarmerRow> saveFarmers(List<FarmerMutation> mutations) {
|
||||
public List<FarmerRow> saveFarmers(String actorId, List<FarmerMutation> mutations) {
|
||||
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
|
||||
for (FarmerMutation mutation : mutations) {
|
||||
if (isBlank(mutation.name())) {
|
||||
continue;
|
||||
@@ -191,7 +197,8 @@ public class CatalogService {
|
||||
return listFarmerRows();
|
||||
}
|
||||
|
||||
public List<MedicationRow> saveMedications(List<MedicationMutation> mutations) {
|
||||
public List<MedicationRow> saveMedications(String actorId, List<MedicationMutation> mutations) {
|
||||
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
|
||||
for (MedicationMutation mutation : mutations) {
|
||||
if (isBlank(mutation.name()) || mutation.category() == null) {
|
||||
continue;
|
||||
@@ -254,7 +261,8 @@ public class CatalogService {
|
||||
return listMedicationRows();
|
||||
}
|
||||
|
||||
public List<PathogenRow> savePathogens(List<PathogenMutation> mutations) {
|
||||
public List<PathogenRow> savePathogens(String actorId, List<PathogenMutation> mutations) {
|
||||
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
|
||||
for (PathogenMutation mutation : mutations) {
|
||||
if (isBlank(mutation.name()) || mutation.kind() == null) {
|
||||
continue;
|
||||
@@ -322,7 +330,8 @@ public class CatalogService {
|
||||
return listPathogenRows();
|
||||
}
|
||||
|
||||
public List<AntibioticRow> saveAntibiotics(List<AntibioticMutation> mutations) {
|
||||
public List<AntibioticRow> saveAntibiotics(String actorId, List<AntibioticMutation> mutations) {
|
||||
authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
|
||||
for (AntibioticMutation mutation : mutations) {
|
||||
if (isBlank(mutation.name())) {
|
||||
continue;
|
||||
|
||||
@@ -36,20 +36,20 @@ public class PortalService {
|
||||
|
||||
List<PortalSampleRow> sampleRows;
|
||||
if (sampleNumber != null) {
|
||||
sampleRows = List.of(toPortalRow(sampleService.getSampleByNumber(sampleNumber)));
|
||||
sampleRows = List.of(toPortalRow(sampleService.getSampleByNumber(actorId, sampleNumber)));
|
||||
} else if (farmerBusinessKey != null && !farmerBusinessKey.isBlank()) {
|
||||
sampleRows = sampleService.samplesByFarmerBusinessKey(farmerBusinessKey).stream()
|
||||
sampleRows = sampleService.samplesByFarmerBusinessKey(actorId, farmerBusinessKey).stream()
|
||||
.filter(sample -> cowQuery == null || cowQuery.isBlank() || cowMatches(sample, cowQuery))
|
||||
.map(this::toPortalRow)
|
||||
.sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed())
|
||||
.toList();
|
||||
} else if (date != null) {
|
||||
sampleRows = sampleService.samplesByDate(date).stream()
|
||||
sampleRows = sampleService.samplesByDate(actorId, date).stream()
|
||||
.map(this::toPortalRow)
|
||||
.sorted(Comparator.comparing(PortalSampleRow::completedAt, Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.toList();
|
||||
} else {
|
||||
sampleRows = sampleService.completedSamples().stream()
|
||||
sampleRows = sampleService.completedSamples(actorId).stream()
|
||||
.limit(25)
|
||||
.map(this::toPortalRow)
|
||||
.toList();
|
||||
@@ -58,13 +58,13 @@ public class PortalService {
|
||||
return new PortalSnapshot(
|
||||
matchingFarmers,
|
||||
sampleRows,
|
||||
reportService.reportCandidates(),
|
||||
reportService.reportCandidates(actorId),
|
||||
includeUsers ? catalogService.listUsers(actorId) : List.of()
|
||||
);
|
||||
}
|
||||
|
||||
public List<PortalSampleRow> searchSamplesByCreatedDate(LocalDate date) {
|
||||
return sampleService.samplesByCreatedDate(date).stream()
|
||||
public List<PortalSampleRow> searchSamplesByCreatedDate(String actorId, LocalDate date) {
|
||||
return sampleService.samplesByCreatedDate(actorId, date).stream()
|
||||
.map(this::toPortalRow)
|
||||
.sorted(Comparator.comparing(PortalSampleRow::createdAt).reversed())
|
||||
.toList();
|
||||
|
||||
@@ -53,8 +53,8 @@ public class ReportService {
|
||||
this.reportMailTemplate = loadTemplate(reportMailTemplateResource);
|
||||
}
|
||||
|
||||
public List<ReportCandidate> reportCandidates() {
|
||||
return sampleService.completedSamples().stream()
|
||||
public List<ReportCandidate> reportCandidates(String actorId) {
|
||||
return sampleService.completedSamples(actorId).stream()
|
||||
.filter(sample -> sample.farmerEmail() != null && !sample.farmerEmail().isBlank())
|
||||
.map(this::toCandidate)
|
||||
.toList();
|
||||
@@ -66,7 +66,7 @@ public class ReportService {
|
||||
List<ReportCandidate> skipped = new ArrayList<>();
|
||||
|
||||
for (String sampleId : sampleIds) {
|
||||
Sample sample = sampleService.loadSampleEntity(sampleId);
|
||||
Sample sample = sampleService.loadSampleEntity(actorId, sampleId);
|
||||
if (sample.farmerEmail() == null || sample.farmerEmail().isBlank() || sample.reportBlocked()) {
|
||||
skipped.add(toCandidate(sample));
|
||||
continue;
|
||||
@@ -82,12 +82,13 @@ public class ReportService {
|
||||
return new DispatchResult(sent, skipped, mailEnabled && mailSenderProvider.getIfAvailable() != null);
|
||||
}
|
||||
|
||||
public byte[] reportPdf(String sampleId) {
|
||||
return buildPdf(sampleService.loadSampleEntity(sampleId));
|
||||
public byte[] reportPdf(String actorId, String sampleId) {
|
||||
return buildPdf(sampleService.loadSampleEntity(actorId, sampleId));
|
||||
}
|
||||
|
||||
public SampleService.SampleDetail toggleReportBlocked(String sampleId, boolean blocked) {
|
||||
return sampleService.getSample(sampleService.toggleReportBlocked(sampleId, blocked).id());
|
||||
public SampleService.SampleDetail toggleReportBlocked(String actorId, String sampleId, boolean blocked) {
|
||||
Sample sample = sampleService.loadSampleEntity(actorId, sampleId);
|
||||
return sampleService.getSample(actorId, sampleService.toggleReportBlocked(sample.id(), blocked).id());
|
||||
}
|
||||
|
||||
private void sendMail(Sample sample, byte[] pdf, String customerSignature) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.svencarstensen.muh.service;
|
||||
|
||||
import de.svencarstensen.muh.domain.AntibiogramEntry;
|
||||
import de.svencarstensen.muh.domain.AppUser;
|
||||
import de.svencarstensen.muh.domain.PathogenCatalogItem;
|
||||
import de.svencarstensen.muh.domain.PathogenKind;
|
||||
import de.svencarstensen.muh.domain.QuarterAntibiogram;
|
||||
@@ -13,6 +14,8 @@ import de.svencarstensen.muh.domain.SamplingMode;
|
||||
import de.svencarstensen.muh.domain.SensitivityResult;
|
||||
import de.svencarstensen.muh.domain.TherapyRecommendation;
|
||||
import de.svencarstensen.muh.repository.SampleRepository;
|
||||
import de.svencarstensen.muh.repository.AppUserRepository;
|
||||
import de.svencarstensen.muh.security.AuthorizationService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
@@ -25,33 +28,54 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class SampleService {
|
||||
|
||||
private final SampleRepository sampleRepository;
|
||||
private final CatalogService catalogService;
|
||||
private final AppUserRepository appUserRepository;
|
||||
private final AuthorizationService authorizationService;
|
||||
|
||||
public SampleService(SampleRepository sampleRepository, CatalogService catalogService) {
|
||||
public SampleService(
|
||||
SampleRepository sampleRepository,
|
||||
CatalogService catalogService,
|
||||
AppUserRepository appUserRepository,
|
||||
AuthorizationService authorizationService
|
||||
) {
|
||||
this.sampleRepository = sampleRepository;
|
||||
this.catalogService = catalogService;
|
||||
this.appUserRepository = appUserRepository;
|
||||
this.authorizationService = authorizationService;
|
||||
}
|
||||
|
||||
public DashboardOverview dashboardOverview() {
|
||||
List<SampleSummary> recent = sampleRepository.findTop12ByOrderByUpdatedAtDesc().stream()
|
||||
public DashboardOverview dashboardOverview(String actorId) {
|
||||
AppUser actor = requireActor(actorId);
|
||||
List<Sample> accessibleSamples = accessibleSamples(actor);
|
||||
List<SampleSummary> recent = accessibleSamples.stream()
|
||||
.sorted(Comparator.comparing(Sample::updatedAt).reversed())
|
||||
.limit(12)
|
||||
.map(this::toSummary)
|
||||
.toList();
|
||||
long openCount = sampleRepository.findAll().stream().filter(sample -> sample.currentStep() != SampleWorkflowStep.COMPLETED).count();
|
||||
long openCount = accessibleSamples.stream()
|
||||
.filter(sample -> sample.currentStep() != SampleWorkflowStep.COMPLETED)
|
||||
.count();
|
||||
LocalDate today = LocalDate.now();
|
||||
long completedToday = sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(
|
||||
today.atStartOfDay(),
|
||||
today.plusDays(1).atStartOfDay()
|
||||
).size();
|
||||
long completedToday = accessibleSamples.stream()
|
||||
.filter(sample -> sample.completedAt() != null)
|
||||
.filter(sample -> !sample.completedAt().isBefore(today.atStartOfDay()))
|
||||
.filter(sample -> sample.completedAt().isBefore(today.plusDays(1).atStartOfDay()))
|
||||
.count();
|
||||
return new DashboardOverview(nextSampleNumber(), openCount, completedToday, recent);
|
||||
}
|
||||
|
||||
public LookupResult lookup(long sampleNumber) {
|
||||
public LookupResult lookup(String actorId, long sampleNumber) {
|
||||
AppUser actor = requireActor(actorId);
|
||||
return sampleRepository.findBySampleNumber(sampleNumber)
|
||||
.filter(sample -> canAccess(actor, sample))
|
||||
.map(sample -> new LookupResult(
|
||||
true,
|
||||
"Probe gefunden",
|
||||
@@ -62,17 +86,20 @@ public class SampleService {
|
||||
.orElseGet(() -> new LookupResult(false, "Proben-Nummer unbekannt", null, null, null));
|
||||
}
|
||||
|
||||
public SampleDetail getSample(String id) {
|
||||
return toDetail(loadSample(id));
|
||||
public SampleDetail getSample(String actorId, String id) {
|
||||
return toDetail(loadAccessibleSample(actorId, id));
|
||||
}
|
||||
|
||||
public SampleDetail getSampleByNumber(long sampleNumber) {
|
||||
public SampleDetail getSampleByNumber(String actorId, long sampleNumber) {
|
||||
AppUser actor = requireActor(actorId);
|
||||
return sampleRepository.findBySampleNumber(sampleNumber)
|
||||
.filter(sample -> canAccess(actor, sample))
|
||||
.map(this::toDetail)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden"));
|
||||
}
|
||||
|
||||
public SampleDetail createSample(RegistrationRequest request) {
|
||||
public SampleDetail createSample(String actorId, RegistrationRequest request) {
|
||||
AppUser actor = requireActor(actorId);
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
CatalogService.FarmerOption farmer = catalogService.activeCatalogSummary().farmers().stream()
|
||||
.filter(candidate -> candidate.businessKey().equals(request.farmerBusinessKey()))
|
||||
@@ -99,6 +126,7 @@ public class SampleService {
|
||||
now,
|
||||
now,
|
||||
null,
|
||||
authorizationService.accountId(actor),
|
||||
request.userCode(),
|
||||
request.userDisplayName()
|
||||
);
|
||||
@@ -106,8 +134,8 @@ public class SampleService {
|
||||
return toDetail(sampleRepository.save(sample));
|
||||
}
|
||||
|
||||
public SampleDetail saveRegistration(String id, RegistrationRequest request) {
|
||||
Sample existing = loadSample(id);
|
||||
public SampleDetail saveRegistration(String actorId, String id, RegistrationRequest request) {
|
||||
Sample existing = loadAccessibleSample(actorId, id);
|
||||
if (!SampleWorkflowRules.canEditRegistration(existing)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Stammdaten können nicht mehr geändert werden");
|
||||
}
|
||||
@@ -137,6 +165,7 @@ public class SampleService {
|
||||
existing.createdAt(),
|
||||
LocalDateTime.now(),
|
||||
existing.completedAt(),
|
||||
existing.ownerAccountId(),
|
||||
existing.createdByUserCode(),
|
||||
existing.createdByDisplayName()
|
||||
));
|
||||
@@ -144,8 +173,8 @@ public class SampleService {
|
||||
return toDetail(saved);
|
||||
}
|
||||
|
||||
public SampleDetail saveAnamnesis(String id, AnamnesisRequest request) {
|
||||
Sample existing = loadSample(id);
|
||||
public SampleDetail saveAnamnesis(String actorId, String id, AnamnesisRequest request) {
|
||||
Sample existing = loadAccessibleSample(actorId, id);
|
||||
if (!SampleWorkflowRules.canEditAnamnesis(existing)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Anamnese kann an dieser Stelle nicht geändert werden");
|
||||
}
|
||||
@@ -203,14 +232,15 @@ public class SampleService {
|
||||
existing.createdAt(),
|
||||
LocalDateTime.now(),
|
||||
existing.completedAt(),
|
||||
existing.ownerAccountId(),
|
||||
existing.createdByUserCode(),
|
||||
existing.createdByDisplayName()
|
||||
));
|
||||
return toDetail(saved);
|
||||
}
|
||||
|
||||
public SampleDetail saveAntibiogram(String id, AntibiogramRequest request) {
|
||||
Sample existing = loadSample(id);
|
||||
public SampleDetail saveAntibiogram(String actorId, String id, AntibiogramRequest request) {
|
||||
Sample existing = loadAccessibleSample(actorId, id);
|
||||
if (!SampleWorkflowRules.canEditAntibiogram(existing)) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Antibiogramm kann nicht mehr geändert werden");
|
||||
}
|
||||
@@ -293,14 +323,15 @@ public class SampleService {
|
||||
existing.createdAt(),
|
||||
LocalDateTime.now(),
|
||||
existing.completedAt(),
|
||||
existing.ownerAccountId(),
|
||||
existing.createdByUserCode(),
|
||||
existing.createdByDisplayName()
|
||||
));
|
||||
return toDetail(saved);
|
||||
}
|
||||
|
||||
public SampleDetail saveTherapy(String id, TherapyRequest request) {
|
||||
Sample existing = loadSample(id);
|
||||
public SampleDetail saveTherapy(String actorId, String id, TherapyRequest request) {
|
||||
Sample existing = loadAccessibleSample(actorId, id);
|
||||
if (existing.currentStep() == SampleWorkflowStep.COMPLETED) {
|
||||
TherapyRecommendation previous = existing.therapyRecommendation();
|
||||
TherapyRecommendation updated = previous == null
|
||||
@@ -341,6 +372,7 @@ public class SampleService {
|
||||
existing.createdAt(),
|
||||
LocalDateTime.now(),
|
||||
existing.completedAt(),
|
||||
existing.ownerAccountId(),
|
||||
existing.createdByUserCode(),
|
||||
existing.createdByDisplayName()
|
||||
)));
|
||||
@@ -388,6 +420,7 @@ public class SampleService {
|
||||
existing.createdAt(),
|
||||
LocalDateTime.now(),
|
||||
LocalDateTime.now(),
|
||||
existing.ownerAccountId(),
|
||||
existing.createdByUserCode(),
|
||||
existing.createdByDisplayName()
|
||||
));
|
||||
@@ -416,6 +449,7 @@ public class SampleService {
|
||||
existing.createdAt(),
|
||||
LocalDateTime.now(),
|
||||
existing.completedAt(),
|
||||
existing.ownerAccountId(),
|
||||
existing.createdByUserCode(),
|
||||
existing.createdByDisplayName()
|
||||
));
|
||||
@@ -443,25 +477,41 @@ public class SampleService {
|
||||
existing.createdAt(),
|
||||
LocalDateTime.now(),
|
||||
existing.completedAt(),
|
||||
existing.ownerAccountId(),
|
||||
existing.createdByUserCode(),
|
||||
existing.createdByDisplayName()
|
||||
));
|
||||
}
|
||||
|
||||
public List<Sample> completedSamples() {
|
||||
return sampleRepository.findByCompletedAtNotNullOrderByCompletedAtDesc();
|
||||
public List<Sample> completedSamples(String actorId) {
|
||||
return accessibleSamples(requireActor(actorId)).stream()
|
||||
.filter(sample -> sample.completedAt() != null)
|
||||
.sorted(Comparator.comparing(Sample::completedAt).reversed())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<Sample> samplesByFarmerBusinessKey(String businessKey) {
|
||||
return sampleRepository.findByFarmerBusinessKeyOrderByCreatedAtDesc(businessKey);
|
||||
public List<Sample> samplesByFarmerBusinessKey(String actorId, String businessKey) {
|
||||
return accessibleSamples(requireActor(actorId)).stream()
|
||||
.filter(sample -> Objects.equals(sample.farmerBusinessKey(), businessKey))
|
||||
.sorted(Comparator.comparing(Sample::createdAt).reversed())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<Sample> samplesByCreatedDate(LocalDate date) {
|
||||
return sampleRepository.findByCreatedAtBetweenOrderByCreatedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay());
|
||||
public List<Sample> samplesByCreatedDate(String actorId, LocalDate date) {
|
||||
return accessibleSamples(requireActor(actorId)).stream()
|
||||
.filter(sample -> !sample.createdAt().isBefore(date.atStartOfDay()))
|
||||
.filter(sample -> sample.createdAt().isBefore(date.plusDays(1).atStartOfDay()))
|
||||
.sorted(Comparator.comparing(Sample::createdAt).reversed())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public List<Sample> samplesByDate(LocalDate date) {
|
||||
return sampleRepository.findByCompletedAtBetweenOrderByCompletedAtDesc(date.atStartOfDay(), date.plusDays(1).atStartOfDay());
|
||||
public List<Sample> samplesByDate(String actorId, LocalDate date) {
|
||||
return accessibleSamples(requireActor(actorId)).stream()
|
||||
.filter(sample -> sample.completedAt() != null)
|
||||
.filter(sample -> !sample.completedAt().isBefore(date.atStartOfDay()))
|
||||
.filter(sample -> sample.completedAt().isBefore(date.plusDays(1).atStartOfDay()))
|
||||
.sorted(Comparator.comparing(Sample::completedAt).reversed())
|
||||
.toList();
|
||||
}
|
||||
|
||||
public long nextSampleNumber() {
|
||||
@@ -470,15 +520,119 @@ public class SampleService {
|
||||
.orElse(100001L);
|
||||
}
|
||||
|
||||
public Sample loadSampleEntity(String id) {
|
||||
return loadSample(id);
|
||||
public Sample loadSampleEntity(String actorId, String id) {
|
||||
return loadAccessibleSample(actorId, id);
|
||||
}
|
||||
|
||||
private AppUser requireActor(String actorId) {
|
||||
ensureSampleOwnershipMigration();
|
||||
return authorizationService.requireActiveUser(actorId, "Nicht berechtigt");
|
||||
}
|
||||
|
||||
private List<Sample> accessibleSamples(AppUser actor) {
|
||||
List<Sample> samples = sampleRepository.findAll();
|
||||
if (authorizationService.isAdmin(actor)) {
|
||||
return samples;
|
||||
}
|
||||
String accountId = authorizationService.accountId(actor);
|
||||
return samples.stream()
|
||||
.filter(sample -> Objects.equals(sample.ownerAccountId(), accountId))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private boolean canAccess(AppUser actor, Sample sample) {
|
||||
return authorizationService.isAdmin(actor)
|
||||
|| Objects.equals(sample.ownerAccountId(), authorizationService.accountId(actor));
|
||||
}
|
||||
|
||||
private Sample loadAccessibleSample(String actorId, String id) {
|
||||
AppUser actor = requireActor(actorId);
|
||||
Sample sample = loadSample(id);
|
||||
if (!canAccess(actor, sample)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden");
|
||||
}
|
||||
return sample;
|
||||
}
|
||||
|
||||
private Sample loadSample(String id) {
|
||||
ensureSampleOwnershipMigration();
|
||||
return sampleRepository.findById(Objects.requireNonNull(id))
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Probe nicht gefunden"));
|
||||
}
|
||||
|
||||
private void ensureSampleOwnershipMigration() {
|
||||
List<Sample> samples = sampleRepository.findAll().stream()
|
||||
.filter(sample -> sample.ownerAccountId() == null || sample.ownerAccountId().isBlank())
|
||||
.toList();
|
||||
if (samples.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<AppUser> users = appUserRepository.findAll().stream()
|
||||
.filter(AppUser::active)
|
||||
.toList();
|
||||
Map<String, List<AppUser>> usersByDisplayName = users.stream()
|
||||
.filter(user -> user.displayName() != null && !user.displayName().isBlank())
|
||||
.collect(Collectors.groupingBy(user -> user.displayName().trim().toLowerCase()));
|
||||
List<String> primaryCustomerAccounts = users.stream()
|
||||
.filter(user -> user.role() != de.svencarstensen.muh.domain.UserRole.ADMIN)
|
||||
.filter(user -> Boolean.TRUE.equals(user.primaryUser()))
|
||||
.map(authorizationService::accountId)
|
||||
.distinct()
|
||||
.toList();
|
||||
String fallbackAccountId = primaryCustomerAccounts.size() == 1 ? primaryCustomerAccounts.get(0) : null;
|
||||
|
||||
for (Sample sample : samples) {
|
||||
String resolvedAccountId = resolveSampleOwnerAccountId(sample, usersByDisplayName, fallbackAccountId);
|
||||
if (resolvedAccountId == null) {
|
||||
continue;
|
||||
}
|
||||
sampleRepository.save(new Sample(
|
||||
sample.id(),
|
||||
sample.sampleNumber(),
|
||||
sample.farmerBusinessKey(),
|
||||
sample.farmerName(),
|
||||
sample.farmerEmail(),
|
||||
sample.cowNumber(),
|
||||
sample.cowName(),
|
||||
sample.sampleKind(),
|
||||
sample.samplingMode(),
|
||||
sample.currentStep(),
|
||||
sample.quarters(),
|
||||
sample.antibiograms(),
|
||||
sample.therapyRecommendation(),
|
||||
sample.reportSent(),
|
||||
sample.reportBlocked(),
|
||||
sample.reportSentAt(),
|
||||
sample.createdAt(),
|
||||
sample.updatedAt(),
|
||||
sample.completedAt(),
|
||||
resolvedAccountId,
|
||||
sample.createdByUserCode(),
|
||||
sample.createdByDisplayName()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveSampleOwnerAccountId(
|
||||
Sample sample,
|
||||
Map<String, List<AppUser>> usersByDisplayName,
|
||||
String fallbackAccountId
|
||||
) {
|
||||
if (sample.createdByDisplayName() != null && !sample.createdByDisplayName().isBlank()) {
|
||||
List<String> matchingAccounts = usersByDisplayName
|
||||
.getOrDefault(sample.createdByDisplayName().trim().toLowerCase(), List.of())
|
||||
.stream()
|
||||
.map(authorizationService::accountId)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (matchingAccounts.size() == 1) {
|
||||
return matchingAccounts.get(0);
|
||||
}
|
||||
}
|
||||
return fallbackAccountId;
|
||||
}
|
||||
|
||||
private SampleSummary toSummary(Sample sample) {
|
||||
return new SampleSummary(
|
||||
sample.id(),
|
||||
|
||||
@@ -32,27 +32,27 @@ public class CatalogController {
|
||||
|
||||
@GetMapping("/admin")
|
||||
public CatalogService.AdministrationOverview administrationOverview() {
|
||||
return catalogService.administrationOverview();
|
||||
return catalogService.administrationOverview(securitySupport.currentUser().id());
|
||||
}
|
||||
|
||||
@PostMapping("/admin/farmers")
|
||||
public List<CatalogService.FarmerRow> saveFarmers(@RequestBody List<CatalogService.FarmerMutation> mutations) {
|
||||
return catalogService.saveFarmers(mutations);
|
||||
return catalogService.saveFarmers(securitySupport.currentUser().id(), mutations);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/medications")
|
||||
public List<CatalogService.MedicationRow> saveMedications(@RequestBody List<CatalogService.MedicationMutation> mutations) {
|
||||
return catalogService.saveMedications(mutations);
|
||||
return catalogService.saveMedications(securitySupport.currentUser().id(), mutations);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/pathogens")
|
||||
public List<CatalogService.PathogenRow> savePathogens(@RequestBody List<CatalogService.PathogenMutation> mutations) {
|
||||
return catalogService.savePathogens(mutations);
|
||||
return catalogService.savePathogens(securitySupport.currentUser().id(), mutations);
|
||||
}
|
||||
|
||||
@PostMapping("/admin/antibiotics")
|
||||
public List<CatalogService.AntibioticRow> saveAntibiotics(@RequestBody List<CatalogService.AntibioticMutation> mutations) {
|
||||
return catalogService.saveAntibiotics(mutations);
|
||||
return catalogService.saveAntibiotics(securitySupport.currentUser().id(), mutations);
|
||||
}
|
||||
|
||||
@GetMapping("/portal/users")
|
||||
|
||||
@@ -58,12 +58,12 @@ public class PortalController {
|
||||
|
||||
@GetMapping("/reports")
|
||||
public List<ReportService.ReportCandidate> reports() {
|
||||
return reportService.reportCandidates();
|
||||
return reportService.reportCandidates(securitySupport.currentUser().id());
|
||||
}
|
||||
|
||||
@GetMapping("/search/by-date")
|
||||
public List<PortalService.PortalSampleRow> searchByDate(@RequestParam LocalDate date) {
|
||||
return portalService.searchSamplesByCreatedDate(date);
|
||||
return portalService.searchSamplesByCreatedDate(securitySupport.currentUser().id(), date);
|
||||
}
|
||||
|
||||
@PostMapping("/reports/send")
|
||||
@@ -73,12 +73,12 @@ public class PortalController {
|
||||
|
||||
@PatchMapping("/reports/{sampleId}/block")
|
||||
public SampleService.SampleDetail block(@PathVariable String sampleId, @RequestBody BlockRequest request) {
|
||||
return reportService.toggleReportBlocked(sampleId, request.blocked());
|
||||
return reportService.toggleReportBlocked(securitySupport.currentUser().id(), sampleId, request.blocked());
|
||||
}
|
||||
|
||||
@GetMapping("/reports/{sampleId}/pdf")
|
||||
public ResponseEntity<byte[]> pdf(@PathVariable String sampleId) {
|
||||
byte[] pdf = reportService.reportPdf(sampleId);
|
||||
byte[] pdf = reportService.reportPdf(securitySupport.currentUser().id(), sampleId);
|
||||
return ResponseEntity.ok()
|
||||
.contentType(Objects.requireNonNull(MediaType.APPLICATION_PDF))
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline()
|
||||
|
||||
@@ -25,28 +25,28 @@ public class SampleController {
|
||||
|
||||
@GetMapping("/dashboard")
|
||||
public SampleService.DashboardOverview dashboardOverview() {
|
||||
return sampleService.dashboardOverview();
|
||||
return sampleService.dashboardOverview(securitySupport.currentUser().id());
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard/lookup/{sampleNumber}")
|
||||
public SampleService.LookupResult lookup(@PathVariable long sampleNumber) {
|
||||
return sampleService.lookup(sampleNumber);
|
||||
return sampleService.lookup(securitySupport.currentUser().id(), sampleNumber);
|
||||
}
|
||||
|
||||
@GetMapping("/samples/{id}")
|
||||
public SampleService.SampleDetail sample(@PathVariable String id) {
|
||||
return sampleService.getSample(id);
|
||||
return sampleService.getSample(securitySupport.currentUser().id(), id);
|
||||
}
|
||||
|
||||
@GetMapping("/samples/by-number/{sampleNumber}")
|
||||
public SampleService.SampleDetail sampleByNumber(@PathVariable long sampleNumber) {
|
||||
return sampleService.getSampleByNumber(sampleNumber);
|
||||
return sampleService.getSampleByNumber(securitySupport.currentUser().id(), sampleNumber);
|
||||
}
|
||||
|
||||
@PostMapping("/samples")
|
||||
public SampleService.SampleDetail create(@RequestBody SampleService.RegistrationRequest request) {
|
||||
AuthenticatedUser user = securitySupport.currentUser();
|
||||
return sampleService.createSample(new SampleService.RegistrationRequest(
|
||||
return sampleService.createSample(user.id(), new SampleService.RegistrationRequest(
|
||||
request.farmerBusinessKey(),
|
||||
request.cowNumber(),
|
||||
request.cowName(),
|
||||
@@ -60,22 +60,22 @@ public class SampleController {
|
||||
|
||||
@PutMapping("/samples/{id}/registration")
|
||||
public SampleService.SampleDetail saveRegistration(@PathVariable String id, @RequestBody SampleService.RegistrationRequest request) {
|
||||
return sampleService.saveRegistration(id, request);
|
||||
return sampleService.saveRegistration(securitySupport.currentUser().id(), id, request);
|
||||
}
|
||||
|
||||
@PutMapping("/samples/{id}/anamnesis")
|
||||
public SampleService.SampleDetail saveAnamnesis(@PathVariable String id, @RequestBody SampleService.AnamnesisRequest request) {
|
||||
return sampleService.saveAnamnesis(id, request);
|
||||
return sampleService.saveAnamnesis(securitySupport.currentUser().id(), id, request);
|
||||
}
|
||||
|
||||
@PutMapping("/samples/{id}/antibiogram")
|
||||
public SampleService.SampleDetail saveAntibiogram(@PathVariable String id, @RequestBody SampleService.AntibiogramRequest request) {
|
||||
return sampleService.saveAntibiogram(id, request);
|
||||
return sampleService.saveAntibiogram(securitySupport.currentUser().id(), id, request);
|
||||
}
|
||||
|
||||
@PutMapping("/samples/{id}/therapy")
|
||||
public SampleService.SampleDetail saveTherapy(@PathVariable String id, @RequestBody SampleService.TherapyRequest request) {
|
||||
return sampleService.saveTherapy(id, request);
|
||||
return sampleService.saveTherapy(securitySupport.currentUser().id(), id, request);
|
||||
}
|
||||
|
||||
private String deriveUserLabel(String displayName) {
|
||||
|
||||
@@ -34,7 +34,7 @@ muh:
|
||||
cors:
|
||||
allowed-origins: ${MUH_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
|
||||
security:
|
||||
token-secret: ${MUH_TOKEN_SECRET:change-me-in-production}
|
||||
token-secret: ${MUH_TOKEN_SECRET:}
|
||||
token-validity-hours: ${MUH_TOKEN_VALIDITY_HOURS:12}
|
||||
mongodb:
|
||||
url: ${MUH_MONGODB_URL:mongodb://192.168.180.25:27017/muh}
|
||||
|
||||
@@ -16,6 +16,7 @@ import UserManagementPage from "./pages/UserManagementPage";
|
||||
|
||||
function ProtectedRoutes() {
|
||||
const { user, ready } = useSession();
|
||||
const isAdmin = user?.role === "ADMIN";
|
||||
|
||||
if (!ready) {
|
||||
return <div className="empty-state">Sitzung wird geladen ...</div>;
|
||||
@@ -34,7 +35,7 @@ function ProtectedRoutes() {
|
||||
<Route path="/samples/:sampleId/anamnesis" element={<AnamnesisPage />} />
|
||||
<Route path="/samples/:sampleId/antibiogram" element={<AntibiogramPage />} />
|
||||
<Route path="/samples/:sampleId/therapy" element={<TherapyPage />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/landwirte" replace />} />
|
||||
<Route path="/admin" element={<Navigate to={isAdmin ? "/admin/landwirte" : "/admin/benutzer"} replace />} />
|
||||
<Route path="/admin/benutzer" element={<UserManagementPage />} />
|
||||
<Route path="/admin/landwirte" element={<AdministrationPage />} />
|
||||
<Route path="/admin/medikamente" element={<AdministrationPage />} />
|
||||
|
||||
@@ -21,19 +21,19 @@ function resolvePageTitle(pathname: string) {
|
||||
return "Probe bearbeiten";
|
||||
}
|
||||
if (pathname.startsWith("/admin/landwirte")) {
|
||||
return "Verwaltung | Landwirte";
|
||||
return "Die Verwaltung der Landwirte";
|
||||
}
|
||||
if (pathname.startsWith("/admin/benutzer")) {
|
||||
return "Verwaltung | Benutzer";
|
||||
}
|
||||
if (pathname.startsWith("/admin/medikamente")) {
|
||||
return "Verwaltung | Medikamente";
|
||||
return "Die Verwaltung der Medikamente";
|
||||
}
|
||||
if (pathname.startsWith("/admin/erreger")) {
|
||||
return "Verwaltung | Erreger";
|
||||
return "Die Verwaltung der Erreger";
|
||||
}
|
||||
if (pathname.startsWith("/admin/antibiogramm")) {
|
||||
return "Verwaltung | Antibiogramm";
|
||||
return "Die Verwaltung der Antibiogramme";
|
||||
}
|
||||
if (pathname.startsWith("/search/landwirt")) {
|
||||
return "Suche | Landwirt";
|
||||
@@ -79,9 +79,6 @@ export default function AppShell() {
|
||||
<div className="nav-group">
|
||||
<div className="nav-group__label">Verwaltung</div>
|
||||
<div className="nav-subnav">
|
||||
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||
Benutzer
|
||||
</NavLink>
|
||||
<NavLink to="/admin/landwirte" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||
Landwirte
|
||||
</NavLink>
|
||||
@@ -94,6 +91,9 @@ export default function AppShell() {
|
||||
<NavLink to="/admin/antibiogramm" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||
Antibiogramm
|
||||
</NavLink>
|
||||
<NavLink to="/admin/benutzer" className={({ isActive }) => `nav-sublink ${isActive ? "is-active" : ""}`}>
|
||||
Benutzer
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ const DATASET_LABELS: Record<DatasetKey, string> = {
|
||||
antibiotics: "Antibiogramm",
|
||||
};
|
||||
|
||||
const DATASET_TITLES: Record<DatasetKey, string> = {
|
||||
farmers: "Die Verwaltung der Landwirte",
|
||||
medications: "Die Verwaltung der Medikamente",
|
||||
pathogens: "Die Verwaltung der Erreger",
|
||||
antibiotics: "Die Verwaltung der Antibiogramme",
|
||||
};
|
||||
|
||||
function normalizeOverview(overview: AdministrationOverview): DatasetsState {
|
||||
return {
|
||||
farmers: overview.farmers.map((entry) => ({
|
||||
@@ -216,7 +223,7 @@ export default function AdministrationPage() {
|
||||
<section className="section-card section-card--hero">
|
||||
<div>
|
||||
<p className="eyebrow">Verwaltung</p>
|
||||
<h3>Stammdaten direkt pflegen</h3>
|
||||
<h3>{DATASET_TITLES[selectedDataset]}</h3>
|
||||
<p className="muted-text">
|
||||
Bestehende Datensaetze lassen sich inline aendern. Bei Umbenennungen bleibt der alte
|
||||
Satz inaktiv sichtbar.
|
||||
|
||||
Reference in New Issue
Block a user