feat(sprint-6): Phase 2 — DSGVO consent management
Deploy to Production / test (push) Has been cancelled
Deploy to Production / deploy (push) Has been cancelled

- 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:
Patrick Plate
2026-06-12 22:22:48 +02:00
parent b38902a7ee
commit 3232d2f7fd
17 changed files with 2227 additions and 0 deletions
@@ -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);
}
}
@@ -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);
}
@@ -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);
}