package jobs import ( "archive/zip" "bytes" "context" "encoding/json" "fmt" "time" "github.com/hibiken/asynq" "github.com/rs/zerolog/log" "github.com/treytartt/honeydue-api/internal/models" "github.com/treytartt/honeydue-api/internal/services" "github.com/treytartt/honeydue-api/internal/worker" ) // HandleDataExport gathers all of a user's data (GDPR data portability), zips it // as one JSON file per category, and emails the archive as an attachment. // Triggered by POST /api/auth/export/ -> Enqueuer.EnqueueDataExport. // // Residence-scoped data (tasks, contractors, documents, share codes) covers only // residences the user OWNS — shared residences belong to their owner and are // intentionally excluded. Document/photo *files* are referenced by URL (in // documents.json); the bytes live in B2 and aren't inlined. func (h *Handler) HandleDataExport(ctx context.Context, task *asynq.Task) error { var payload worker.DataExportPayload if err := json.Unmarshal(task.Payload(), &payload); err != nil { log.Error().Err(err).Msg("data export: malformed payload") return asynq.SkipRetry } db := h.db.WithContext(ctx) var user models.User if err := db.First(&user, payload.UserID).Error; err != nil { log.Error().Err(err).Uint("user_id", payload.UserID).Msg("data export: user not found") return err } if h.emailService == nil { log.Warn().Uint("user_id", payload.UserID).Msg("data export: email service unavailable; cannot deliver") return nil // retrying won't help a structurally-disabled mailer } var ownedIDs []uint db.Model(&models.Residence{}).Where("owner_id = ?", payload.UserID).Pluck("id", &ownedIDs) var ( profile []models.UserProfile residences []models.Residence tasks []models.Task contractors []models.Contractor documents []models.Document shareCodes []models.ResidenceShareCode notifs []models.Notification notifPrefs []models.NotificationPreference apnsDevices []models.APNSDevice gcmDevices []models.GCMDevice subscription []models.UserSubscription auditLog []models.AuditLog ) db.Where("user_id = ?", payload.UserID).Find(&profile) db.Where("owner_id = ?", payload.UserID).Find(&residences) if len(ownedIDs) > 0 { db.Where("residence_id IN ?", ownedIDs).Find(&tasks) db.Where("residence_id IN ?", ownedIDs).Find(&contractors) db.Where("residence_id IN ?", ownedIDs).Find(&documents) db.Where("residence_id IN ?", ownedIDs).Find(&shareCodes) } db.Where("user_id = ?", payload.UserID).Find(¬ifs) db.Where("user_id = ?", payload.UserID).Find(¬ifPrefs) db.Where("user_id = ?", payload.UserID).Find(&apnsDevices) db.Where("user_id = ?", payload.UserID).Find(&gcmDevices) db.Where("user_id = ?", payload.UserID).Find(&subscription) db.Where("user_id = ?", payload.UserID).Find(&auditLog) sections := []struct { name string data interface{} }{ {"account", user}, {"profile", profile}, {"residences", residences}, {"tasks", tasks}, {"contractors", contractors}, {"documents", documents}, {"share_codes", shareCodes}, {"notifications", notifs}, {"notification_preferences", notifPrefs}, {"push_tokens_ios", apnsDevices}, {"push_tokens_android", gcmDevices}, {"subscription", subscription}, {"audit_log", auditLog}, } var buf bytes.Buffer zw := zip.NewWriter(&buf) readme := fmt.Sprintf("honeyDue data export\nGenerated: %s UTC\nAccount: %s\n\n"+ "One JSON file per data category. Residence-scoped data covers residences you own.\n"+ "Document and photo files are referenced by URL in documents.json.\n", time.Now().UTC().Format(time.RFC3339), user.Email) if w, err := zw.Create("README.txt"); err == nil { _, _ = w.Write([]byte(readme)) } for _, s := range sections { w, err := zw.Create(s.name + ".json") if err != nil { _ = zw.Close() return fmt.Errorf("data export: zip create %s: %w", s.name, err) } enc := json.NewEncoder(w) enc.SetIndent("", " ") if err := enc.Encode(s.data); err != nil { _ = zw.Close() return fmt.Errorf("data export: encode %s: %w", s.name, err) } } if err := zw.Close(); err != nil { return fmt.Errorf("data export: finalize zip: %w", err) } subject := "Your honeyDue data export" text := "Attached is a copy of your honeyDue data, as a zip of JSON files.\n" + "If you didn't request this, you can ignore this email.\n" html := "

Attached is a copy of your honeyDue data, as a zip of JSON files.

" + "

If you didn't request this, you can ignore this email.

" attach := &services.EmailAttachment{ Filename: "honeydue-data-export.zip", ContentType: "application/zip", Data: buf.Bytes(), } if err := h.emailService.SendEmailWithAttachment(user.Email, subject, html, text, attach); err != nil { log.Error().Err(err).Uint("user_id", payload.UserID).Msg("data export: email send failed") return err } log.Info().Uint("user_id", payload.UserID).Int("zip_bytes", buf.Len()).Msg("data export emailed") return nil }