b54493f785
BE-3 observability: expose the worker's Prometheus metrics on :6060/metrics (apns/fcm/asynq histograms + a new cache_ops_total counter were recorded all along but never scraped — which is why those dashboard panels read empty); add the worker containerPort, the vmagent worker scrape job, and two additive NetworkPolicies. Instrument cache Get/Set hit/miss. BE-2 retention: three periodic Asynq cleanup crons mirroring the reminder-log cleanup — notifications (90d), webhook dedup log (180d), audit_log (365d). BE-1 GDPR data export: POST /api/auth/export/ enqueues a low-priority Asynq job that gathers all of the user's data (owned residences + their tasks/contractors/ documents/share-codes, plus profile/notifications/prefs/push-tokens/subscription/ audit log), zips one JSON file per category, and emails it as an attachment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
139 lines
4.9 KiB
Go
139 lines
4.9 KiB
Go
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 := "<p>Attached is a copy of your honeyDue data, as a zip of JSON files.</p>" +
|
|
"<p>If you didn't request this, you can ignore this email.</p>"
|
|
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
|
|
}
|