From 788b6cf43059ce51becbb5c6a5ebdd1be50044fd Mon Sep 17 00:00:00 2001 From: David Folch Agulles Date: Wed, 3 Dec 2025 22:32:22 +0100 Subject: [PATCH] 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) --- cmd/api/main.go | 4 +- .../application/commands/mark_habit_test.go | 4 + .../queries/export_user_data_handler.go | 104 ++++++++++++++++++ .../queries/get_todays_habits_test.go | 4 + .../repositories/habit_entry_repository.go | 1 + internal/i18n/locales/en.json | 1 + internal/i18n/locales/es.json | 1 + .../infrastructure/http/export_handlers.go | 65 +++++++++++ .../infrastructure/http/integration_test.go | 4 +- internal/infrastructure/http/router.go | 8 +- .../sqlite/habit_entry_repository.go | 18 +++ 11 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 internal/application/queries/export_user_data_handler.go create mode 100644 internal/infrastructure/http/export_handlers.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 3cc41e2..f792011 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -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) diff --git a/internal/application/commands/mark_habit_test.go b/internal/application/commands/mark_habit_test.go index b2c5dac..27a697a 100644 --- a/internal/application/commands/mark_habit_test.go +++ b/internal/application/commands/mark_habit_test.go @@ -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 } diff --git a/internal/application/queries/export_user_data_handler.go b/internal/application/queries/export_user_data_handler.go new file mode 100644 index 0000000..3aea26f --- /dev/null +++ b/internal/application/queries/export_user_data_handler.go @@ -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 +} diff --git a/internal/application/queries/get_todays_habits_test.go b/internal/application/queries/get_todays_habits_test.go index 4b2ae2f..9e17985 100644 --- a/internal/application/queries/get_todays_habits_test.go +++ b/internal/application/queries/get_todays_habits_test.go @@ -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 } diff --git a/internal/domain/repositories/habit_entry_repository.go b/internal/domain/repositories/habit_entry_repository.go index 944137d..d2ea8ab 100644 --- a/internal/domain/repositories/habit_entry_repository.go +++ b/internal/domain/repositories/habit_entry_repository.go @@ -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 diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index ed54c30..d9f9ee1 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -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)" }, diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index b826d6d..95b7e4c 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -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)" }, diff --git a/internal/infrastructure/http/export_handlers.go b/internal/infrastructure/http/export_handlers.go new file mode 100644 index 0000000..b4cf787 --- /dev/null +++ b/internal/infrastructure/http/export_handlers.go @@ -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 + } +} diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go index ecc285d..e01a5b8 100644 --- a/internal/infrastructure/http/integration_test.go +++ b/internal/infrastructure/http/integration_test.go @@ -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{ diff --git a/internal/infrastructure/http/router.go b/internal/infrastructure/http/router.go index 6b972fd..2c1e68b 100644 --- a/internal/infrastructure/http/router.go +++ b/internal/infrastructure/http/router.go @@ -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 } diff --git a/internal/infrastructure/persistence/sqlite/habit_entry_repository.go b/internal/infrastructure/persistence/sqlite/habit_entry_repository.go index 1562667..ac4ed28 100644 --- a/internal/infrastructure/persistence/sqlite/habit_entry_repository.go +++ b/internal/infrastructure/persistence/sqlite/habit_entry_repository.go @@ -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