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.api.controller;
import de.cannamanage.domain.entity.Consent;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.ConsentType;
import de.cannamanage.service.ConsentService;
import de.cannamanage.service.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/v1/consent")
@RequiredArgsConstructor
@Tag(name = "Consent", description = "DSGVO consent management")
public class ConsentController {
private final ConsentService consentService;
private final UserRepository userRepository;
@GetMapping
@Operation(summary = "Get current user's consents")
public ResponseEntity<List<ConsentResponse>> getConsents(Authentication auth) {
UUID userId = resolveUserId(auth);
List<ConsentResponse> consents = consentService.getUserConsents(userId).stream()
.map(this::toResponse)
.toList();
return ResponseEntity.ok(consents);
}
@PostMapping
@Operation(summary = "Grant consent")
public ResponseEntity<ConsentResponse> grantConsent(
@RequestBody GrantConsentRequest request,
Authentication auth,
HttpServletRequest httpRequest) {
UUID userId = resolveUserId(auth);
String ipAddress = httpRequest.getRemoteAddr();
String userAgent = httpRequest.getHeader("User-Agent");
Consent consent = consentService.grantConsent(
userId,
request.type(),
request.version() != null ? request.version() : 1,
ipAddress,
userAgent
);
return ResponseEntity.ok(toResponse(consent));
}
@DeleteMapping("/{type}")
@Operation(summary = "Revoke consent")
public ResponseEntity<Void> revokeConsent(@PathVariable String type, Authentication auth) {
UUID userId = resolveUserId(auth);
ConsentType consentType = ConsentType.valueOf(type);
consentService.revokeConsent(userId, consentType);
return ResponseEntity.noContent().build();
}
@GetMapping("/check")
@Operation(summary = "Check if user has required DATA_PROCESSING consent")
public ResponseEntity<Map<String, Boolean>> checkConsent(Authentication auth) {
UUID userId = resolveUserId(auth);
boolean hasConsent = consentService.hasRequiredConsents(userId);
return ResponseEntity.ok(Map.of("hasDataProcessingConsent", hasConsent));
}
private UUID resolveUserId(Authentication auth) {
String email = auth.getName();
return userRepository.findByEmailAndTenantId(email, TenantContext.getCurrentTenant())
.map(User::getId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
private ConsentResponse toResponse(Consent consent) {
return new ConsentResponse(
consent.getId(),
consent.getConsentType().name(),
consent.isGranted(),
consent.getGrantedAt() != null ? consent.getGrantedAt().toString() : null,
consent.getRevokedAt() != null ? consent.getRevokedAt().toString() : null,
consent.getVersion()
);
}
public record GrantConsentRequest(ConsentType type, Integer version) {}
public record ConsentResponse(UUID id, String type, boolean granted, String grantedAt, String revokedAt, int version) {}
}
@@ -0,0 +1,62 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.entity.User;
import de.cannamanage.service.DsgvoService;
import de.cannamanage.service.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
import java.util.UUID;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@RestController
@RequestMapping("/api/v1/dsgvo")
@RequiredArgsConstructor
@Tag(name = "DSGVO", description = "Data export and deletion (GDPR Art. 15 & 17)")
public class DsgvoController {
private final DsgvoService dsgvoService;
private final UserRepository userRepository;
/**
* Art. 15 DSGVO — Export all personal data as JSON.
*/
@GetMapping("/export")
@Operation(summary = "Export all personal data (Art. 15 DSGVO)")
public ResponseEntity<Map<String, Object>> exportData(Authentication auth) {
UUID userId = resolveUserId(auth);
UUID tenantId = TenantContext.getCurrentTenant();
Map<String, Object> data = dsgvoService.exportUserData(userId, tenantId);
return ResponseEntity.ok(data);
}
/**
* Art. 17 DSGVO — Right to erasure.
* Anonymizes personal data, deactivates account.
*/
@DeleteMapping("/delete")
@Operation(summary = "Delete account and anonymize data (Art. 17 DSGVO)")
public ResponseEntity<Map<String, String>> deleteAccount(Authentication auth) {
UUID userId = resolveUserId(auth);
dsgvoService.deleteUserData(userId);
return ResponseEntity.ok(Map.of(
"status", "deleted",
"message", "Dein Konto wurde gelöscht und deine Daten anonymisiert."
));
}
private UUID resolveUserId(Authentication auth) {
String email = auth.getName();
return userRepository.findByEmailAndTenantId(email, TenantContext.getCurrentTenant())
.map(User::getId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found"));
}
}
@@ -0,0 +1,21 @@
-- V6: DSGVO Consent Management
-- Tracks user consent for data processing, marketing, analytics per GDPR Art. 6/7
CREATE TABLE IF NOT EXISTS consents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
consent_type VARCHAR(50) NOT NULL, -- 'DATA_PROCESSING', 'MARKETING', 'ANALYTICS'
granted BOOLEAN NOT NULL DEFAULT false,
granted_at TIMESTAMP WITH TIME ZONE,
revoked_at TIMESTAMP WITH TIME ZONE,
ip_address VARCHAR(45),
user_agent TEXT,
version INTEGER NOT NULL DEFAULT 1, -- consent text version
tenant_id UUID NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_consents_user ON consents(user_id);
CREATE INDEX idx_consents_tenant ON consents(tenant_id);
CREATE INDEX idx_consents_type ON consents(consent_type, user_id);