feat(sprint-6): Phase 2 — DSGVO consent management
- V6 migration: consents table with audit columns - Consent entity, repository, service (grant/revoke/check) - ConsentController: GET/POST/DELETE consent endpoints - DSGVO export (Art. 15): full personal data JSON download - DSGVO deletion (Art. 17): anonymization + account deactivation - Frontend: consent banner (modal, cannot dismiss), privacy settings page - React Query hooks for consent + DSGVO operations - Full i18n (de/en) for consent and DSGVO namespaces
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Consent;
|
||||
import de.cannamanage.domain.enums.ConsentType;
|
||||
import de.cannamanage.service.repository.ConsentRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Manages DSGVO consent lifecycle: grant, revoke, check.
|
||||
*/
|
||||
@Service
|
||||
public class ConsentService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(ConsentService.class);
|
||||
|
||||
private final ConsentRepository consentRepository;
|
||||
|
||||
public ConsentService(ConsentRepository consentRepository) {
|
||||
this.consentRepository = consentRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a specific consent type for a user.
|
||||
* Creates a new record or re-grants a previously revoked one.
|
||||
*/
|
||||
@Transactional
|
||||
public Consent grantConsent(UUID userId, ConsentType type, int version, String ipAddress, String userAgent) {
|
||||
Consent consent = consentRepository.findByUserIdAndConsentType(userId, type)
|
||||
.orElseGet(() -> {
|
||||
Consent c = new Consent();
|
||||
c.setUserId(userId);
|
||||
c.setConsentType(type);
|
||||
return c;
|
||||
});
|
||||
|
||||
consent.setGranted(true);
|
||||
consent.setGrantedAt(Instant.now());
|
||||
consent.setRevokedAt(null);
|
||||
consent.setVersion(version);
|
||||
consent.setIpAddress(ipAddress);
|
||||
consent.setUserAgent(userAgent);
|
||||
consent.setUpdatedAt(Instant.now());
|
||||
|
||||
log.info("Consent granted: userId={}, type={}, version={}", userId, type, version);
|
||||
return consentRepository.save(consent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific consent type for a user.
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeConsent(UUID userId, ConsentType type) {
|
||||
consentRepository.findByUserIdAndConsentType(userId, type)
|
||||
.ifPresent(consent -> {
|
||||
consent.setGranted(false);
|
||||
consent.setRevokedAt(Instant.now());
|
||||
consent.setUpdatedAt(Instant.now());
|
||||
consentRepository.save(consent);
|
||||
log.info("Consent revoked: userId={}, type={}", userId, type);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all consents for a user (used during account deletion).
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeAllConsents(UUID userId) {
|
||||
List<Consent> consents = consentRepository.findByUserIdAndGrantedTrue(userId);
|
||||
Instant now = Instant.now();
|
||||
for (Consent consent : consents) {
|
||||
consent.setGranted(false);
|
||||
consent.setRevokedAt(now);
|
||||
consent.setUpdatedAt(now);
|
||||
}
|
||||
consentRepository.saveAll(consents);
|
||||
log.info("All consents revoked for userId={}", userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user has the required DATA_PROCESSING consent.
|
||||
*/
|
||||
public boolean hasRequiredConsents(UUID userId) {
|
||||
return consentRepository.existsByUserIdAndConsentTypeAndGrantedTrue(userId, ConsentType.DATA_PROCESSING);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all consent records for a user.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Consent> getUserConsents(UUID userId) {
|
||||
return consentRepository.findByUserId(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.Consent;
|
||||
import de.cannamanage.domain.entity.Distribution;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.service.repository.ConsentRepository;
|
||||
import de.cannamanage.service.repository.DistributionRepository;
|
||||
import de.cannamanage.service.repository.MemberRepository;
|
||||
import de.cannamanage.service.repository.UserRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* DSGVO compliance service — data export (Art. 15) and data deletion (Art. 17).
|
||||
*/
|
||||
@Service
|
||||
public class DsgvoService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DsgvoService.class);
|
||||
private static final String ANONYMIZED = "GELÖSCHT";
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final MemberRepository memberRepository;
|
||||
private final DistributionRepository distributionRepository;
|
||||
private final ConsentRepository consentRepository;
|
||||
private final ConsentService consentService;
|
||||
|
||||
public DsgvoService(UserRepository userRepository,
|
||||
MemberRepository memberRepository,
|
||||
DistributionRepository distributionRepository,
|
||||
ConsentRepository consentRepository,
|
||||
ConsentService consentService) {
|
||||
this.userRepository = userRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.distributionRepository = distributionRepository;
|
||||
this.consentRepository = consentRepository;
|
||||
this.consentService = consentService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export all personal data for a user (Art. 15 DSGVO).
|
||||
* Returns a structured map suitable for JSON serialization.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public Map<String, Object> exportUserData(UUID userId, UUID tenantId) {
|
||||
Map<String, Object> export = new LinkedHashMap<>();
|
||||
export.put("exportDate", Instant.now().toString());
|
||||
export.put("legalBasis", "Art. 15 DSGVO — Auskunftsrecht der betroffenen Person");
|
||||
|
||||
// Personal data from user account
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user != null) {
|
||||
Map<String, Object> personal = new LinkedHashMap<>();
|
||||
personal.put("email", user.getEmail());
|
||||
personal.put("role", user.getRole().name());
|
||||
personal.put("active", user.isActive());
|
||||
personal.put("lastLogin", user.getLastLogin() != null ? user.getLastLogin().toString() : null);
|
||||
personal.put("createdAt", user.getCreatedAt() != null ? user.getCreatedAt().toString() : null);
|
||||
export.put("personalData", personal);
|
||||
}
|
||||
|
||||
// Member profile data
|
||||
if (user != null && user.getMemberId() != null) {
|
||||
memberRepository.findById(user.getMemberId()).ifPresent(member -> {
|
||||
Map<String, Object> profile = new LinkedHashMap<>();
|
||||
profile.put("firstName", member.getFirstName());
|
||||
profile.put("lastName", member.getLastName());
|
||||
profile.put("dateOfBirth", member.getDateOfBirth() != null ? member.getDateOfBirth().toString() : null);
|
||||
profile.put("memberNumber", member.getMemberNumber());
|
||||
profile.put("status", member.getStatus().name());
|
||||
profile.put("joinedAt", member.getCreatedAt() != null ? member.getCreatedAt().toString() : null);
|
||||
export.put("memberProfile", profile);
|
||||
});
|
||||
}
|
||||
|
||||
// Distribution history
|
||||
if (user != null && user.getMemberId() != null) {
|
||||
UUID memberId = user.getMemberId();
|
||||
List<Distribution> distributions = distributionRepository.findByMemberIdAndTenantId(memberId, tenantId);
|
||||
List<Map<String, Object>> distList = distributions.stream()
|
||||
.map(d -> {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("id", d.getId().toString());
|
||||
entry.put("amountGrams", d.getQuantityGrams());
|
||||
entry.put("distributedAt", d.getDistributedAt() != null ? d.getDistributedAt().toString() : null);
|
||||
return entry;
|
||||
})
|
||||
.toList();
|
||||
export.put("distributions", distList);
|
||||
}
|
||||
|
||||
// Consent records
|
||||
List<Consent> consents = consentRepository.findByUserId(userId);
|
||||
List<Map<String, Object>> consentList = consents.stream()
|
||||
.map(c -> {
|
||||
Map<String, Object> entry = new LinkedHashMap<>();
|
||||
entry.put("type", c.getConsentType().name());
|
||||
entry.put("granted", c.isGranted());
|
||||
entry.put("grantedAt", c.getGrantedAt() != null ? c.getGrantedAt().toString() : null);
|
||||
entry.put("revokedAt", c.getRevokedAt() != null ? c.getRevokedAt().toString() : null);
|
||||
entry.put("version", c.getVersion());
|
||||
return entry;
|
||||
})
|
||||
.toList();
|
||||
export.put("consents", consentList);
|
||||
|
||||
return export;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete/anonymize all personal data for a user (Art. 17 DSGVO).
|
||||
* - Anonymizes personal data (name, email → "GELÖSCHT")
|
||||
* - Keeps distribution records (compliance) but anonymizes member reference
|
||||
* - Revokes all consents
|
||||
* - Deactivates account
|
||||
*/
|
||||
@Transactional
|
||||
public void deleteUserData(UUID userId) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||
|
||||
// Anonymize member profile
|
||||
if (user.getMemberId() != null) {
|
||||
memberRepository.findById(user.getMemberId()).ifPresent(member -> {
|
||||
member.setFirstName(ANONYMIZED);
|
||||
member.setLastName(ANONYMIZED);
|
||||
member.setEmail(ANONYMIZED + "@deleted.local");
|
||||
member.setPhone(null);
|
||||
memberRepository.save(member);
|
||||
log.info("Member profile anonymized: memberId={}", member.getId());
|
||||
});
|
||||
}
|
||||
|
||||
// Revoke all consents
|
||||
consentService.revokeAllConsents(userId);
|
||||
|
||||
// Anonymize and deactivate user account
|
||||
user.setEmail(ANONYMIZED + "_" + userId + "@deleted.local");
|
||||
user.setPasswordHash(ANONYMIZED);
|
||||
user.setRefreshTokenHash(null);
|
||||
user.setActive(false);
|
||||
userRepository.save(user);
|
||||
|
||||
log.info("User data deleted/anonymized: userId={}", userId);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package de.cannamanage.service.repository;
|
||||
|
||||
import de.cannamanage.domain.entity.Consent;
|
||||
import de.cannamanage.domain.enums.ConsentType;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Repository
|
||||
public interface ConsentRepository extends JpaRepository<Consent, UUID> {
|
||||
|
||||
Optional<Consent> findByUserIdAndConsentType(UUID userId, ConsentType consentType);
|
||||
|
||||
List<Consent> findByUserId(UUID userId);
|
||||
|
||||
List<Consent> findByUserIdAndGrantedTrue(UUID userId);
|
||||
|
||||
boolean existsByUserIdAndConsentTypeAndGrantedTrue(UUID userId, ConsentType consentType);
|
||||
}
|
||||
+5
@@ -64,4 +64,9 @@ public interface DistributionRepository extends JpaRepository<Distribution, UUID
|
||||
* Paginated distribution history for a member, newest first (portal history).
|
||||
*/
|
||||
Page<Distribution> findByMemberIdAndTenantIdOrderByDistributedAtDesc(UUID memberId, UUID tenantId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* Find all distributions for a specific member (for DSGVO export).
|
||||
*/
|
||||
List<Distribution> findByMemberIdAndTenantId(UUID memberId, UUID tenantId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user