diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 9001cec3..aceba510 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -34,7 +34,7 @@ import { AllergiesModule } from './modules/allergies/allergies.module'; import { ConditionsModule } from './modules/conditions/conditions.module'; import { VerificationModule } from './modules/verification/verification.module'; -import { ZkpModule } from './modules/zkp/zkp.module'; +import { GdprModule } from './modules/gdpr/gdpr.module'; // File Upload & Storage Modules import { StorageModule } from './modules/storage/storage.module'; @@ -109,7 +109,7 @@ import { WebSocketModule } from './websocket/websocket.module'; ConditionsModule, VerificationModule, - ZkpModule, + GdprModule, // File Upload, Storage, Security & Processing StorageModule, diff --git a/backend/src/modules/gdpr/dto/gdpr.dto.ts b/backend/src/modules/gdpr/dto/gdpr.dto.ts new file mode 100644 index 00000000..2c03fb42 --- /dev/null +++ b/backend/src/modules/gdpr/dto/gdpr.dto.ts @@ -0,0 +1,16 @@ +import { IsEnum, IsBoolean, IsOptional, IsString } from 'class-validator'; +import { ConsentType } from '../entities/user-consent.entity'; + +export class UpdateConsentDto { + @IsEnum(ConsentType) + type: ConsentType; + + @IsBoolean() + granted: boolean; +} + +export class RequestDeletionDto { + @IsOptional() + @IsString() + reason?: string; +} diff --git a/backend/src/modules/gdpr/entities/data-deletion-request.entity.ts b/backend/src/modules/gdpr/entities/data-deletion-request.entity.ts new file mode 100644 index 00000000..1b3bd171 --- /dev/null +++ b/backend/src/modules/gdpr/entities/data-deletion-request.entity.ts @@ -0,0 +1,37 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, +} from 'typeorm'; + +export enum DeletionStatus { + PENDING = 'pending', + PROCESSING = 'processing', + COMPLETED = 'completed', + FAILED = 'failed', +} + +@Entity('data_deletion_requests') +export class DataDeletionRequest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ type: 'enum', enum: DeletionStatus, default: DeletionStatus.PENDING }) + status: DeletionStatus; + + @Column({ type: 'text', nullable: true }) + reason: string | null; + + @Column({ type: 'jsonb', nullable: true, name: 'deleted_entities' }) + deletedEntities: Record | null; + + @Column({ type: 'timestamptz', nullable: true, name: 'completed_at' }) + completedAt: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/backend/src/modules/gdpr/entities/user-consent.entity.ts b/backend/src/modules/gdpr/entities/user-consent.entity.ts new file mode 100644 index 00000000..74b2cc54 --- /dev/null +++ b/backend/src/modules/gdpr/entities/user-consent.entity.ts @@ -0,0 +1,41 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum ConsentType { + MARKETING = 'marketing', + ANALYTICS = 'analytics', + DATA_SHARING = 'data_sharing', + ESSENTIAL = 'essential', +} + +@Entity('user_consents') +export class UserConsent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id' }) + userId: string; + + @Column({ type: 'enum', enum: ConsentType }) + type: ConsentType; + + @Column({ default: false }) + granted: boolean; + + @Column({ type: 'inet', nullable: true, name: 'ip_address' }) + ipAddress: string | null; + + @Column({ nullable: true, name: 'user_agent' }) + userAgent: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/backend/src/modules/gdpr/gdpr.controller.ts b/backend/src/modules/gdpr/gdpr.controller.ts new file mode 100644 index 00000000..ce50e997 --- /dev/null +++ b/backend/src/modules/gdpr/gdpr.controller.ts @@ -0,0 +1,70 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Req, + HttpCode, + HttpStatus, + Headers, +} from '@nestjs/common'; +import { Request } from 'express'; +import { GdprService } from './gdpr.service'; +import { UpdateConsentDto, RequestDeletionDto } from './dto/gdpr.dto'; + +@Controller('gdpr') +export class GdprController { + constructor(private readonly gdprService: GdprService) {} + + /** GET /gdpr/users/:userId/consents */ + @Get('users/:userId/consents') + getConsents(@Param('userId') userId: string) { + return this.gdprService.getConsents(userId); + } + + /** POST /gdpr/users/:userId/consents/init */ + @Post('users/:userId/consents/init') + @HttpCode(HttpStatus.CREATED) + initConsents(@Param('userId') userId: string) { + return this.gdprService.initDefaultConsents(userId); + } + + /** PATCH /gdpr/users/:userId/consents */ + @Patch('users/:userId/consents') + updateConsent( + @Param('userId') userId: string, + @Body() dto: UpdateConsentDto, + @Req() req: Request, + @Headers('user-agent') userAgent?: string, + ) { + const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0] ?? req.socket.remoteAddress ?? null; + return this.gdprService.updateConsent(userId, dto, ip ?? undefined, userAgent); + } + + /** POST /gdpr/users/:userId/deletion-request */ + @Post('users/:userId/deletion-request') + @HttpCode(HttpStatus.CREATED) + requestDeletion(@Param('userId') userId: string, @Body() dto: RequestDeletionDto) { + return this.gdprService.requestDeletion(userId, dto); + } + + /** POST /gdpr/deletion-requests/:requestId/process */ + @Post('deletion-requests/:requestId/process') + processDeletion(@Param('requestId') requestId: string) { + return this.gdprService.processDeletion(requestId); + } + + /** GET /gdpr/users/:userId/deletion-status */ + @Get('users/:userId/deletion-status') + getDeletionStatus(@Param('userId') userId: string) { + return this.gdprService.getDeletionStatus(userId); + } + + /** GET /gdpr/users/:userId/export */ + @Get('users/:userId/export') + exportData(@Param('userId') userId: string) { + return this.gdprService.exportUserData(userId); + } +} diff --git a/backend/src/modules/gdpr/gdpr.module.ts b/backend/src/modules/gdpr/gdpr.module.ts new file mode 100644 index 00000000..779ecf27 --- /dev/null +++ b/backend/src/modules/gdpr/gdpr.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserConsent } from './entities/user-consent.entity'; +import { DataDeletionRequest } from './entities/data-deletion-request.entity'; +import { GdprService } from './gdpr.service'; +import { GdprController } from './gdpr.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserConsent, DataDeletionRequest])], + controllers: [GdprController], + providers: [GdprService], + exports: [GdprService], +}) +export class GdprModule {} diff --git a/backend/src/modules/gdpr/gdpr.service.ts b/backend/src/modules/gdpr/gdpr.service.ts new file mode 100644 index 00000000..3dfcf2e0 --- /dev/null +++ b/backend/src/modules/gdpr/gdpr.service.ts @@ -0,0 +1,222 @@ +import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { UserConsent, ConsentType } from './entities/user-consent.entity'; +import { DataDeletionRequest, DeletionStatus } from './entities/data-deletion-request.entity'; +import { UpdateConsentDto, RequestDeletionDto } from './dto/gdpr.dto'; + +@Injectable() +export class GdprService { + private readonly logger = new Logger(GdprService.name); + + constructor( + @InjectRepository(UserConsent) + private readonly consentRepo: Repository, + @InjectRepository(DataDeletionRequest) + private readonly deletionRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + // ── Consent Management ──────────────────────────────────────────────────── + + async getConsents(userId: string): Promise { + return this.consentRepo.find({ where: { userId }, order: { type: 'ASC' } }); + } + + async updateConsent( + userId: string, + dto: UpdateConsentDto, + ipAddress?: string, + userAgent?: string, + ): Promise { + let consent = await this.consentRepo.findOne({ where: { userId, type: dto.type } }); + if (!consent) { + consent = this.consentRepo.create({ userId, type: dto.type }); + } + consent.granted = dto.granted; + consent.ipAddress = ipAddress ?? null; + consent.userAgent = userAgent ?? null; + return this.consentRepo.save(consent); + } + + async initDefaultConsents(userId: string): Promise { + const existing = await this.consentRepo.find({ where: { userId } }); + const existingTypes = new Set(existing.map((c) => c.type)); + + const defaults = Object.values(ConsentType).filter((t) => !existingTypes.has(t)); + if (!defaults.length) return existing; + + const entities = defaults.map((type) => + this.consentRepo.create({ + userId, + type, + granted: type === ConsentType.ESSENTIAL, // essential always on + }), + ); + await this.consentRepo.save(entities); + return this.consentRepo.find({ where: { userId } }); + } + + // ── Right to be Forgotten ───────────────────────────────────────────────── + + async requestDeletion(userId: string, dto: RequestDeletionDto): Promise { + const pending = await this.deletionRepo.findOne({ + where: { userId, status: DeletionStatus.PENDING }, + }); + if (pending) throw new ConflictException('A deletion request is already pending'); + + const request = this.deletionRepo.create({ + userId, + reason: dto.reason ?? null, + status: DeletionStatus.PENDING, + }); + return this.deletionRepo.save(request); + } + + async processDeletion(requestId: string): Promise { + const request = await this.deletionRepo.findOne({ where: { id: requestId } }); + if (!request) throw new NotFoundException(`Deletion request ${requestId} not found`); + + request.status = DeletionStatus.PROCESSING; + await this.deletionRepo.save(request); + + const deletedEntities: Record = {}; + + try { + await this.dataSource.transaction(async (em) => { + const userId = request.userId; + + // Delete in dependency order — pets cascade to vaccinations, records, etc. + const tables: Array<{ table: string; col: string }> = [ + { table: 'vaccination_adverse_reactions', col: 'vaccination_id' }, + { table: 'vaccinations', col: 'pet_id' }, + { table: 'medical_records', col: 'pet_id' }, + { table: 'prescriptions', col: 'pet_id' }, + { table: 'surgeries', col: 'pet_id' }, + { table: 'allergies', col: 'pet_id' }, + { table: 'conditions', col: 'pet_id' }, + { table: 'reminders', col: 'pet_id' }, + { table: 'zkp_proofs', col: 'pet_id' }, + { table: 'pets', col: 'owner_id' }, + { table: 'user_consents', col: 'user_id' }, + { table: 'user_sessions', col: 'user_id' }, + { table: 'user_activity_logs', col: 'user_id' }, + { table: 'user_preferences', col: 'user_id' }, + { table: 'notifications', col: 'user_id' }, + { table: 'reminders', col: 'user_id' }, + ]; + + for (const { table, col } of tables) { + try { + const result = await em.query( + `DELETE FROM "${table}" WHERE "${col}" = $1`, + [userId], + ); + deletedEntities[table] = result[1] ?? 0; + } catch { + // table may not exist in all environments — skip + } + } + + // Anonymise user row instead of hard-deleting (audit trail) + await em.query( + `UPDATE users SET + email = $1, + first_name = 'Deleted', + last_name = 'User', + phone = NULL, + avatar_url = NULL, + address = NULL, + city = NULL, + country = NULL, + date_of_birth = NULL, + is_active = false, + deleted_at = NOW() + WHERE id = $2`, + [`deleted-${userId}@deleted.invalid`, userId], + ); + deletedEntities['users'] = 1; + }); + + request.status = DeletionStatus.COMPLETED; + request.completedAt = new Date(); + request.deletedEntities = deletedEntities; + } catch (err) { + this.logger.error(`Deletion failed for request ${requestId}: ${err.message}`); + request.status = DeletionStatus.FAILED; + } + + return this.deletionRepo.save(request); + } + + async getDeletionStatus(userId: string): Promise { + return this.deletionRepo.findOne({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + // ── Data Portability (Export) ───────────────────────────────────────────── + + async exportUserData(userId: string): Promise> { + const rows = await this.dataSource.query( + `SELECT id, email, first_name, last_name, phone, city, country, created_at + FROM users WHERE id = $1`, + [userId], + ); + if (!rows.length) throw new NotFoundException('User not found'); + + const [pets] = await Promise.all([ + this.dataSource.query(`SELECT * FROM pets WHERE owner_id = $1`, [userId]), + ]); + + const petIds: string[] = pets.map((p: { id: string }) => p.id); + + const [vaccinations, medicalRecords, prescriptions, consents] = await Promise.all([ + petIds.length + ? this.dataSource.query(`SELECT * FROM vaccinations WHERE pet_id = ANY($1)`, [petIds]) + : Promise.resolve([]), + petIds.length + ? this.dataSource.query(`SELECT * FROM medical_records WHERE pet_id = ANY($1)`, [petIds]) + : Promise.resolve([]), + petIds.length + ? this.dataSource.query(`SELECT * FROM prescriptions WHERE pet_id = ANY($1)`, [petIds]) + : Promise.resolve([]), + this.dataSource.query(`SELECT type, granted, created_at FROM user_consents WHERE user_id = $1`, [userId]), + ]); + + return { + exportedAt: new Date().toISOString(), + profile: rows[0], + pets, + vaccinations, + medicalRecords, + prescriptions, + consents, + }; + } + + // ── Retention Policy ────────────────────────────────────────────────────── + + /** Purge soft-deleted users older than retentionDays (default 90) */ + async purgeExpiredData(retentionDays = 90): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - retentionDays); + + const result = await this.dataSource.query( + `SELECT id FROM users WHERE deleted_at IS NOT NULL AND deleted_at < $1`, + [cutoff], + ); + + let purged = 0; + for (const { id } of result) { + const req = await this.requestDeletion(id, { reason: 'retention-policy' }).catch(() => null); + if (req) { + await this.processDeletion(req.id); + purged++; + } + } + this.logger.log(`Retention purge: ${purged} users processed`); + return purged; + } +} diff --git a/src/components/PrivacySettings.tsx b/src/components/PrivacySettings.tsx new file mode 100644 index 00000000..f9177cd2 --- /dev/null +++ b/src/components/PrivacySettings.tsx @@ -0,0 +1,136 @@ +'use client'; + +import { useState } from 'react'; +import { useGdpr } from '@/hooks/useGdpr'; +import { ConsentType } from '@/lib/gdpr'; + +const CONSENT_LABELS: Record = { + essential: { label: 'Essential', description: 'Required for the service to function.', locked: true }, + analytics: { label: 'Analytics', description: 'Help us improve by sharing usage data.' }, + marketing: { label: 'Marketing', description: 'Receive updates and promotional content.' }, + data_sharing: { label: 'Data Sharing', description: 'Share anonymised data with research partners.' }, +}; + +interface Props { + userId: string; +} + +export default function PrivacySettings({ userId }: Props) { + const { consents, loading, error, updateConsent, exportData, requestDeletion } = useGdpr(userId); + const [deletionReason, setDeletionReason] = useState(''); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [deletionRequested, setDeletionRequested] = useState(false); + + const handleDelete = async () => { + const req = await requestDeletion(deletionReason || undefined); + if (req) setDeletionRequested(true); + setShowDeleteConfirm(false); + }; + + return ( +
+ {/* Consent Management */} +
+

Privacy Preferences

+

+ Manage how your data is used. Changes take effect immediately. +

+ + {error && ( +
{error}
+ )} + +
+ {(Object.keys(CONSENT_LABELS) as ConsentType[]).map((type) => { + const meta = CONSENT_LABELS[type]; + const consent = consents.find((c) => c.type === type); + const granted = consent?.granted ?? (type === 'essential'); + + return ( +
+
+

{meta.label}

+

{meta.description}

+
+ +
+ ); + })} +
+
+ + {/* Data Portability */} +
+

Download Your Data

+

+ Export all your data in JSON format (GDPR Article 20). +

+ +
+ + {/* Right to be Forgotten */} +
+

Delete My Account

+

+ Permanently delete your account and all associated data (GDPR Article 17). This cannot be undone. +

+ + {deletionRequested ? ( +
+ Your deletion request has been submitted. You will receive a confirmation once processing is complete. +
+ ) : showDeleteConfirm ? ( +
+