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" }