- Export: table data_exports, POST /me/export (202), GET /me/exports, messages+playback_history - Notification email quand ZIP prêt, rate limit 3/jour - Suppression: keep_public_tracks, anonymisation PII complète (users, user_profiles) - HardDeleteWorker: final anonymization après 30 jours - Frontend: POST export, checkbox keep_public_tracks - MSW handlers pour Storybook
165 lines
5.9 KiB
Go
165 lines
5.9 KiB
Go
package services
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
"gorm.io/gorm"
|
|
|
|
"veza-backend-api/internal/models"
|
|
)
|
|
|
|
// GDPRExportService handles async GDPR data export with ZIP upload and notification (v0.10.8 F065)
|
|
type GDPRExportService struct {
|
|
db *gorm.DB
|
|
dataExportService *DataExportService
|
|
s3Service *S3StorageService
|
|
notificationService *NotificationService
|
|
emailService EmailServiceInterface // optional, for export_ready email
|
|
logger *zap.Logger
|
|
}
|
|
|
|
// NewGDPRExportService creates a new GDPR export service
|
|
func NewGDPRExportService(
|
|
db *gorm.DB,
|
|
dataExportService *DataExportService,
|
|
s3Service *S3StorageService,
|
|
notificationService *NotificationService,
|
|
logger *zap.Logger,
|
|
) *GDPRExportService {
|
|
return &GDPRExportService{
|
|
db: db,
|
|
dataExportService: dataExportService,
|
|
s3Service: s3Service,
|
|
notificationService: notificationService,
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// SetEmailService injects the email service for export-ready notifications
|
|
func (s *GDPRExportService) SetEmailService(es EmailServiceInterface) {
|
|
s.emailService = es
|
|
}
|
|
|
|
// ExportUserDataAsync runs the export in a goroutine, stores in data_exports, and notifies when ready
|
|
func (s *GDPRExportService) ExportUserDataAsync(ctx context.Context, exportID, userID uuid.UUID) {
|
|
go func() {
|
|
exportCtx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
|
defer cancel()
|
|
|
|
// Update status to processing
|
|
s.db.WithContext(exportCtx).Model(&models.DataExport{}).Where("id = ? AND user_id = ?", exportID, userID).
|
|
Update("status", "processing")
|
|
|
|
export, err := s.dataExportService.ExportUserData(exportCtx, userID)
|
|
if err != nil {
|
|
s.logger.Error("GDPR export failed", zap.Error(err), zap.String("user_id", userID.String()))
|
|
errMsg := err.Error()
|
|
s.db.WithContext(exportCtx).Model(&models.DataExport{}).Where("id = ? AND user_id = ?", exportID, userID).
|
|
Updates(map[string]interface{}{"status": "failed", "error_message": errMsg, "completed_at": time.Now()})
|
|
if s.notificationService != nil {
|
|
_ = s.notificationService.CreateNotification(userID, "export_failed", "Data export failed", "Your data export could not be completed. Please try again.", "")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Add cloud files metadata
|
|
var cloudFiles []models.UserFile
|
|
if err := s.db.WithContext(exportCtx).Where("user_id = ?", userID).Find(&cloudFiles).Error; err == nil {
|
|
export.CloudFiles = make([]CloudFileExport, len(cloudFiles))
|
|
for i, f := range cloudFiles {
|
|
export.CloudFiles[i] = CloudFileExport{
|
|
ID: f.ID, Filename: f.Filename, SizeBytes: f.SizeBytes, MimeType: f.MimeType, CreatedAt: f.CreatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add gear metadata
|
|
var gear []models.GearItem
|
|
if err := s.db.WithContext(exportCtx).Where("user_id = ?", userID).Find(&gear).Error; err == nil {
|
|
export.Gear = make([]GearExport, len(gear))
|
|
for i, g := range gear {
|
|
export.Gear[i] = GearExport{
|
|
ID: g.ID, Name: g.Name, Category: g.Category, Brand: g.Brand, Model: g.Model, CreatedAt: g.CreatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
jsonData, err := json.MarshalIndent(export, "", " ")
|
|
if err != nil {
|
|
s.logger.Error("GDPR export marshal failed", zap.Error(err))
|
|
return
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
zw := zip.NewWriter(&buf)
|
|
w, err := zw.Create("veza-export.json")
|
|
if err != nil {
|
|
s.logger.Error("GDPR export zip create failed", zap.Error(err))
|
|
return
|
|
}
|
|
if _, err := w.Write(jsonData); err != nil {
|
|
zw.Close()
|
|
s.logger.Error("GDPR export zip write failed", zap.Error(err))
|
|
return
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
s.logger.Error("GDPR export zip close failed", zap.Error(err))
|
|
return
|
|
}
|
|
|
|
key := fmt.Sprintf("exports/%s/veza-export-%s.zip", userID, time.Now().Format("20060102-150405"))
|
|
fileSize := int64(buf.Len())
|
|
if s.s3Service != nil {
|
|
if _, err := s.s3Service.UploadFile(exportCtx, buf.Bytes(), key, "application/zip"); err != nil {
|
|
s.logger.Error("GDPR export S3 upload failed", zap.Error(err))
|
|
s.db.WithContext(exportCtx).Model(&models.DataExport{}).Where("id = ? AND user_id = ?", exportID, userID).
|
|
Updates(map[string]interface{}{"status": "failed", "error_message": "S3 upload failed", "completed_at": time.Now()})
|
|
return
|
|
}
|
|
|
|
// Update data_export with completed status (v0.10.8)
|
|
s.db.WithContext(exportCtx).Model(&models.DataExport{}).Where("id = ? AND user_id = ?", exportID, userID).
|
|
Updates(map[string]interface{}{
|
|
"status": "completed",
|
|
"s3_key": key,
|
|
"file_size_bytes": fileSize,
|
|
"completed_at": time.Now(),
|
|
})
|
|
|
|
// Download: link to frontend settings (user must be logged in to download via API)
|
|
frontendURL := getFrontendURL()
|
|
settingsURL := fmt.Sprintf("%s/settings?tab=export", frontendURL)
|
|
|
|
if s.notificationService != nil {
|
|
_ = s.notificationService.CreateNotification(userID, "export_ready", "Your data export is ready", "Go to Settings to download your personal data (valid 7 days).", settingsURL)
|
|
}
|
|
if s.emailService != nil && export.Profile != nil && export.Profile.Email != "" {
|
|
msg := fmt.Sprintf("Your Veza data export is ready. Go to your account settings to download it (valid for 7 days): %s", settingsURL)
|
|
_ = s.emailService.SendNotificationEmail(export.Profile.Email, "Your Veza data export is ready", msg, "data_export")
|
|
}
|
|
} else {
|
|
s.logger.Warn("GDPR export: S3 not configured, skipping upload")
|
|
s.db.WithContext(exportCtx).Model(&models.DataExport{}).Where("id = ? AND user_id = ?", exportID, userID).
|
|
Updates(map[string]interface{}{"status": "failed", "error_message": "S3 not configured", "completed_at": time.Now()})
|
|
}
|
|
}()
|
|
}
|
|
|
|
func getFrontendURL() string {
|
|
if u := os.Getenv("FRONTEND_URL"); u != "" {
|
|
return strings.TrimSuffix(u, "/")
|
|
}
|
|
if u := os.Getenv("VITE_FRONTEND_URL"); u != "" {
|
|
return strings.TrimSuffix(u, "/")
|
|
}
|
|
return "http://localhost:5173"
|
|
}
|