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:
+3
-1
@@ -108,6 +108,7 @@ func main() {
|
|||||||
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
|
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
|
||||||
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||||
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
|
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
|
||||||
|
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
|
||||||
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
|
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
|
||||||
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
||||||
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
||||||
@@ -118,8 +119,9 @@ func main() {
|
|||||||
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator)
|
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator)
|
||||||
healthHandlers := httpInfra.NewHealthHandlers(db.Conn(), emailService)
|
healthHandlers := httpInfra.NewHealthHandlers(db.Conn(), emailService)
|
||||||
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
|
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)
|
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||||
log.Printf("Server starting on %s", addr)
|
log.Printf("Server starting on %s", addr)
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
|
|||||||
return nil, nil
|
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) {
|
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||||
return nil, nil
|
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
|
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) {
|
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type HabitEntryRepository interface {
|
|||||||
FindByID(ctx context.Context, id string) (*entities.HabitEntry, error)
|
FindByID(ctx context.Context, id string) (*entities.HabitEntry, error)
|
||||||
FindByHabitID(ctx context.Context, habitID 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)
|
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)
|
FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error)
|
||||||
Update(ctx context.Context, entry *entities.HabitEntry) error
|
Update(ctx context.Context, entry *entities.HabitEntry) error
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"failed_reset_password": "Failed to reset password",
|
"failed_reset_password": "Failed to reset password",
|
||||||
"failed_delete_user": "Failed to delete user",
|
"failed_delete_user": "Failed to delete user",
|
||||||
"failed_get_stats": "Failed to get statistics",
|
"failed_get_stats": "Failed to get statistics",
|
||||||
|
"export_failed": "Failed to export data",
|
||||||
"timezone_required": "Timezone is required",
|
"timezone_required": "Timezone is required",
|
||||||
"invalid_timezone": "Invalid timezone (must be a valid IANA timezone)"
|
"invalid_timezone": "Invalid timezone (must be a valid IANA timezone)"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"failed_reset_password": "Error al restablecer contraseña",
|
"failed_reset_password": "Error al restablecer contraseña",
|
||||||
"failed_delete_user": "Error al eliminar usuario",
|
"failed_delete_user": "Error al eliminar usuario",
|
||||||
"failed_get_stats": "Error al obtener estadísticas",
|
"failed_get_stats": "Error al obtener estadísticas",
|
||||||
|
"export_failed": "Error al exportar datos",
|
||||||
"timezone_required": "La zona horaria es requerida",
|
"timezone_required": "La zona horaria es requerida",
|
||||||
"invalid_timezone": "Zona horaria inválida (debe ser una zona horaria IANA válida)"
|
"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)
|
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
|
||||||
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||||
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
|
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
|
||||||
|
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
|
||||||
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
|
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
|
||||||
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
||||||
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
||||||
@@ -74,8 +75,9 @@ func setupTestServer(t *testing.T) *TestServer {
|
|||||||
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
|
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
|
||||||
healthHandlers := NewHealthHandlers(db, nil)
|
healthHandlers := NewHealthHandlers(db, nil)
|
||||||
userHandlers := NewUserHandlers(deleteUserHandler, translator)
|
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)
|
handler := http.Handler(router)
|
||||||
return &TestServer{
|
return &TestServer{
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
_ "apocapoc-api/docs"
|
_ "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 := chi.NewRouter()
|
||||||
|
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
@@ -77,5 +77,11 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa
|
|||||||
r.Delete("/me", userHandlers.DeleteAccount)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,6 +185,24 @@ func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string
|
|||||||
return r.scanEntries(rows)
|
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) {
|
func (r *HabitEntryRepository) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||||
|
|||||||
Reference in New Issue
Block a user