backend: GDPR export + retention cleanups + worker metrics (BE-1/2/3)
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>
This commit is contained in:
@@ -12,11 +12,17 @@ type Enqueuer interface {
|
||||
EnqueuePasswordResetEmail(to, firstName, code, resetToken string) error
|
||||
EnqueuePasswordChangedEmail(to, firstName string) error
|
||||
EnqueueTaskCompletedNotification(taskID, completionID uint) error
|
||||
EnqueueDataExport(userID uint) error
|
||||
}
|
||||
|
||||
// Verify TaskClient satisfies the interface at compile time.
|
||||
var _ Enqueuer = (*TaskClient)(nil)
|
||||
|
||||
// BuildDataExportPayload marshals a DataExportPayload to JSON bytes.
|
||||
func BuildDataExportPayload(userID uint) ([]byte, error) {
|
||||
return json.Marshal(DataExportPayload{UserID: userID})
|
||||
}
|
||||
|
||||
// BuildWelcomeEmailPayload marshals a WelcomeEmailPayload to JSON bytes.
|
||||
func BuildWelcomeEmailPayload(to, firstName, code string) ([]byte, error) {
|
||||
return json.Marshal(WelcomeEmailPayload{
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
)
|
||||
|
||||
// Data-retention cleanup job types. Registered as periodic crons in
|
||||
// cmd/worker/main.go. These keep transient/log tables from growing unbounded;
|
||||
// none touch user-facing data that the app reads back.
|
||||
const (
|
||||
TypeNotificationCleanup = "maintenance:notification_cleanup"
|
||||
TypeWebhookLogCleanup = "maintenance:webhook_log_cleanup"
|
||||
TypeAuditLogCleanup = "maintenance:audit_log_cleanup"
|
||||
)
|
||||
|
||||
// Retention windows (days).
|
||||
const (
|
||||
notificationRetentionDays = 90
|
||||
webhookLogRetentionDays = 180
|
||||
auditLogRetentionDays = 365 // keep 1 year of security events
|
||||
)
|
||||
|
||||
// HandleNotificationCleanup deletes notification rows older than the retention
|
||||
// window. Notifications are delivery records (push/digest history); 90 days is
|
||||
// ample for any in-app history a client might show.
|
||||
func (h *Handler) HandleNotificationCleanup(ctx context.Context, _ *asynq.Task) error {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -notificationRetentionDays)
|
||||
res := h.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&models.Notification{})
|
||||
if res.Error != nil {
|
||||
log.Error().Err(res.Error).Msg("notification cleanup failed")
|
||||
return res.Error
|
||||
}
|
||||
log.Info().Int64("deleted", res.RowsAffected).Int("retention_days", notificationRetentionDays).Msg("notification cleanup completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleWebhookLogCleanup prunes the webhook dedup log. Rows only matter for the
|
||||
// window in which a provider (Apple/Google) might redeliver an event; 180 days
|
||||
// is a generous safety margin past any real redelivery.
|
||||
func (h *Handler) HandleWebhookLogCleanup(ctx context.Context, _ *asynq.Task) error {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -webhookLogRetentionDays)
|
||||
res := h.db.WithContext(ctx).Where("processed_at < ?", cutoff).Delete(&repositories.WebhookEvent{})
|
||||
if res.Error != nil {
|
||||
log.Error().Err(res.Error).Msg("webhook log cleanup failed")
|
||||
return res.Error
|
||||
}
|
||||
log.Info().Int64("deleted", res.RowsAffected).Int("retention_days", webhookLogRetentionDays).Msg("webhook log cleanup completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleAuditLogCleanup prunes audit events older than the retention window.
|
||||
// One year of security events is retained for compliance/forensics.
|
||||
func (h *Handler) HandleAuditLogCleanup(ctx context.Context, _ *asynq.Task) error {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -auditLogRetentionDays)
|
||||
res := h.db.WithContext(ctx).Where("created_at < ?", cutoff).Delete(&models.AuditLog{})
|
||||
if res.Error != nil {
|
||||
log.Error().Err(res.Error).Msg("audit log cleanup failed")
|
||||
return res.Error
|
||||
}
|
||||
log.Info().Int64("deleted", res.RowsAffected).Int("retention_days", auditLogRetentionDays).Msg("audit log cleanup completed")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
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
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/treytartt/honeydue-api/internal/config"
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
)
|
||||
|
||||
// --- Mock implementations ---
|
||||
@@ -27,6 +28,13 @@ func (m *mockEmailSender) SendEmail(to, subject, htmlBody, textBody string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEmailSender) SendEmailWithAttachment(to, subject, htmlBody, textBody string, _ *services.EmailAttachment) error {
|
||||
if m.sendFn != nil {
|
||||
return m.sendFn(to, subject, htmlBody, textBody)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockPushSender struct {
|
||||
sendFn func(ctx context.Context, iosTokens, androidTokens []string, title, message string, data map[string]string) error
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/models"
|
||||
"github.com/treytartt/honeydue-api/internal/repositories"
|
||||
"github.com/treytartt/honeydue-api/internal/services"
|
||||
)
|
||||
|
||||
// TaskRepo defines task query operations needed by job handlers.
|
||||
@@ -46,6 +47,7 @@ type PushSender interface {
|
||||
// EmailSender sends emails.
|
||||
type EmailSender interface {
|
||||
SendEmail(to, subject, htmlBody, textBody string) error
|
||||
SendEmailWithAttachment(to, subject, htmlBody, textBody string, attachment *services.EmailAttachment) error
|
||||
}
|
||||
|
||||
// OnboardingEmailSender sends onboarding campaign emails.
|
||||
|
||||
@@ -21,8 +21,17 @@ const (
|
||||
// Moves the ~1-1.5s of synchronous APNs+SMTP+B2-fetch work out of the
|
||||
// POST /api/task-completions/ request path.
|
||||
TypeTaskCompletedNotification = "notification:task_completed"
|
||||
|
||||
// TypeDataExport is emitted by POST /api/auth/export/. The worker gathers
|
||||
// all of the user's data into a zip and emails it (GDPR data portability).
|
||||
TypeDataExport = "user:data_export"
|
||||
)
|
||||
|
||||
// DataExportPayload carries just the user id; the worker re-fetches all rows.
|
||||
type DataExportPayload struct {
|
||||
UserID uint `json:"user_id"`
|
||||
}
|
||||
|
||||
// TaskCompletedNotificationPayload carries only the IDs needed for the
|
||||
// worker to re-fetch the canonical Task + TaskCompletion rows. Keeping the
|
||||
// payload to IDs (vs. full model graphs) keeps the Redis queue cheap and
|
||||
@@ -93,6 +102,26 @@ func (c *TaskClient) EnqueueWelcomeEmail(to, firstName, code string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnqueueDataExport enqueues a GDPR data-export task for a user. The worker
|
||||
// gathers the user's data, zips it, and emails it. Low priority — there's no
|
||||
// rush, and it shouldn't compete with notifications for the critical queue.
|
||||
func (c *TaskClient) EnqueueDataExport(userID uint) error {
|
||||
payload, err := BuildDataExportPayload(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task := asynq.NewTask(TypeDataExport, payload)
|
||||
_, err = c.client.Enqueue(task, asynq.Queue("low"), asynq.MaxRetry(3))
|
||||
if err != nil {
|
||||
log.Error().Err(err).Uint("user_id", userID).Msg("Failed to enqueue data export")
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info().Uint("user_id", userID).Msg("Data export task enqueued")
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnqueueVerificationEmail enqueues a verification email task
|
||||
func (c *TaskClient) EnqueueVerificationEmail(to, firstName, code string) error {
|
||||
payload, err := BuildVerificationEmailPayload(to, firstName, code)
|
||||
|
||||
Reference in New Issue
Block a user