Add data export functionality with gzip compression

- Add FindByUserID method to HabitEntryRepository (JOIN with habits)
- Implement ExportUserDataHandler to export all user data
- Add GET /api/v1/export endpoint with gzip compression
- Apply strict rate limiting (1 export per hour per user)
- Export includes all habits (active + archived) and entries
- Add i18n translations for export errors (en/es)
- Update all test mocks to implement new repository method
- Export format: JSON with gzip (~10x compression ratio)
This commit is contained in:
2025-12-03 22:32:22 +01:00
parent 568ba3b016
commit 788b6cf430
11 changed files with 211 additions and 3 deletions
+3 -1
View File
@@ -108,6 +108,7 @@ func main() {
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
@@ -118,8 +119,9 @@ func main() {
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator)
healthHandlers := httpInfra.NewHealthHandlers(db.Conn(), emailService)
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
exportHandlers := httpInfra.NewExportHandlers(exportUserDataHandler, translator)
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
log.Printf("Server starting on %s", addr)
@@ -40,6 +40,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
return nil, nil
}
func (m *mockEntryRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
return nil, nil
}
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
return nil, nil
}
@@ -0,0 +1,104 @@
package queries
import (
"context"
"time"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/value_objects"
)
type ExportHabitDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type value_objects.HabitType `json:"type"`
Frequency value_objects.Frequency `json:"frequency"`
SpecificDays []int `json:"specific_days,omitempty"`
SpecificDates []int `json:"specific_dates,omitempty"`
CarryOver bool `json:"carry_over"`
IsNegative bool `json:"is_negative"`
TargetValue *float64 `json:"target_value,omitempty"`
CreatedAt time.Time `json:"created_at"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
}
type ExportEntryDTO struct {
ID string `json:"id"`
HabitID string `json:"habit_id"`
ScheduledDate time.Time `json:"scheduled_date"`
CompletedAt time.Time `json:"completed_at"`
Value *float64 `json:"value,omitempty"`
}
type ExportUserDataResult struct {
ExportedAt time.Time `json:"exported_at"`
Habits []ExportHabitDTO `json:"habits"`
Entries []ExportEntryDTO `json:"entries"`
}
type ExportUserDataQuery struct {
UserID string
}
type ExportUserDataHandler struct {
habitRepo repositories.HabitRepository
entryRepo repositories.HabitEntryRepository
}
func NewExportUserDataHandler(
habitRepo repositories.HabitRepository,
entryRepo repositories.HabitEntryRepository,
) *ExportUserDataHandler {
return &ExportUserDataHandler{
habitRepo: habitRepo,
entryRepo: entryRepo,
}
}
func (h *ExportUserDataHandler) Handle(ctx context.Context, query ExportUserDataQuery) (*ExportUserDataResult, error) {
habits, err := h.habitRepo.FindByUserID(ctx, query.UserID)
if err != nil {
return nil, err
}
entries, err := h.entryRepo.FindByUserID(ctx, query.UserID)
if err != nil {
return nil, err
}
habitDTOs := make([]ExportHabitDTO, 0, len(habits))
for _, habit := range habits {
habitDTOs = append(habitDTOs, ExportHabitDTO{
ID: habit.ID,
Name: habit.Name,
Description: habit.Description,
Type: habit.Type,
Frequency: habit.Frequency,
SpecificDays: habit.SpecificDays,
SpecificDates: habit.SpecificDates,
CarryOver: habit.CarryOver,
IsNegative: habit.IsNegative,
TargetValue: habit.TargetValue,
CreatedAt: habit.CreatedAt,
ArchivedAt: habit.ArchivedAt,
})
}
entryDTOs := make([]ExportEntryDTO, 0, len(entries))
for _, entry := range entries {
entryDTOs = append(entryDTOs, ExportEntryDTO{
ID: entry.ID,
HabitID: entry.HabitID,
ScheduledDate: entry.ScheduledDate,
CompletedAt: entry.CompletedAt,
Value: entry.Value,
})
}
return &ExportUserDataResult{
ExportedAt: time.Now(),
Habits: habitDTOs,
Entries: entryDTOs,
}, nil
}
@@ -73,6 +73,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
return result, nil
}
func (m *mockEntryRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
return nil, nil
}
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
return nil, nil
}
@@ -12,6 +12,7 @@ type HabitEntryRepository interface {
FindByID(ctx context.Context, id string) (*entities.HabitEntry, error)
FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error)
FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error)
FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error)
FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error)
Update(ctx context.Context, entry *entities.HabitEntry) error
Delete(ctx context.Context, id string) error
+1
View File
@@ -47,6 +47,7 @@
"failed_reset_password": "Failed to reset password",
"failed_delete_user": "Failed to delete user",
"failed_get_stats": "Failed to get statistics",
"export_failed": "Failed to export data",
"timezone_required": "Timezone is required",
"invalid_timezone": "Invalid timezone (must be a valid IANA timezone)"
},
+1
View File
@@ -47,6 +47,7 @@
"failed_reset_password": "Error al restablecer contraseña",
"failed_delete_user": "Error al eliminar usuario",
"failed_get_stats": "Error al obtener estadísticas",
"export_failed": "Error al exportar datos",
"timezone_required": "La zona horaria es requerida",
"invalid_timezone": "Zona horaria inválida (debe ser una zona horaria IANA válida)"
},
@@ -0,0 +1,65 @@
package http
import (
"compress/gzip"
"encoding/json"
"net/http"
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/i18n"
)
type ExportHandlers struct {
exportHandler *queries.ExportUserDataHandler
translator *i18n.Translator
}
func NewExportHandlers(
exportHandler *queries.ExportUserDataHandler,
translator *i18n.Translator,
) *ExportHandlers {
return &ExportHandlers{
exportHandler: exportHandler,
translator: translator,
}
}
// ExportData godoc
// @Summary Export user data
// @Description Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.
// @Tags export
// @Security BearerAuth
// @Produce json
// @Success 200 {object} queries.ExportUserDataResult "Compressed JSON export"
// @Failure 401 {object} ErrorResponse "Unauthorized"
// @Failure 429 {object} ErrorResponse "Rate limit exceeded"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /export [get]
func (h *ExportHandlers) ExportData(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("user_id").(string)
query := queries.ExportUserDataQuery{
UserID: userID,
}
result, err := h.exportHandler.Handle(r.Context(), query)
if err != nil {
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "export_failed")
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Content-Disposition", "attachment; filename=\"apocapoc-export.json.gz\"")
w.WriteHeader(http.StatusOK)
gzipWriter := gzip.NewWriter(w)
defer gzipWriter.Close()
encoder := json.NewEncoder(gzipWriter)
encoder.SetIndent("", " ")
if err := encoder.Encode(result); err != nil {
return
}
}
@@ -58,6 +58,7 @@ func setupTestServer(t *testing.T) *TestServer {
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
@@ -74,8 +75,9 @@ func setupTestServer(t *testing.T) *TestServer {
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
healthHandlers := NewHealthHandlers(db, nil)
userHandlers := NewUserHandlers(deleteUserHandler, translator)
exportHandlers := NewExportHandlers(exportUserDataHandler, translator)
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
handler := http.Handler(router)
return &TestServer{
+7 -1
View File
@@ -16,7 +16,7 @@ import (
_ "apocapoc-api/docs"
)
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, exportHandlers *ExportHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Logger)
@@ -77,5 +77,11 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa
r.Delete("/me", userHandlers.DeleteAccount)
})
r.Route("/api/v1/export", func(r chi.Router) {
r.Use(AuthMiddleware(jwtService))
r.Use(RateLimitByUser(jwtService, 1, 1*time.Hour))
r.Get("/", exportHandlers.ExportData)
})
return r
}
@@ -185,6 +185,24 @@ func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string
return r.scanEntries(rows)
}
func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
query := `
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value
FROM habit_entries he
INNER JOIN habits h ON he.habit_id = h.id
WHERE h.user_id = ?
ORDER BY he.scheduled_date DESC
`
rows, err := r.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("failed to find entries: %w", err)
}
defer rows.Close()
return r.scanEntries(rows)
}
func (r *HabitEntryRepository) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
query := `
SELECT id, habit_id, scheduled_date, completed_at, value