Files
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go
T
david 788b6cf430 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)
2025-12-03 22:32:22 +01:00

239 lines
5.3 KiB
Go

package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
"github.com/google/uuid"
)
type HabitEntryRepository struct {
db *sql.DB
}
func NewHabitEntryRepository(db *sql.DB) *HabitEntryRepository {
return &HabitEntryRepository{db: db}
}
func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.HabitEntry) error {
entry.ID = uuid.New().String()
query := `
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value)
VALUES (?, ?, ?, ?, ?)
`
_, err := r.db.ExecContext(ctx, query,
entry.ID,
entry.HabitID,
entry.ScheduledDate.Format("2006-01-02"),
entry.CompletedAt,
entry.Value,
)
if err != nil {
if isUniqueConstraintError(err) {
return errors.ErrAlreadyExists
}
return fmt.Errorf("failed to create entry: %w", err)
}
return nil
}
func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
ctx context.Context,
habitID string,
from, to time.Time,
) ([]*entities.HabitEntry, error) {
query := `
SELECT id, habit_id, scheduled_date, completed_at, value
FROM habit_entries
WHERE habit_id = ?
AND scheduled_date >= ?
AND scheduled_date <= ?
ORDER BY scheduled_date ASC
`
rows, err := r.db.QueryContext(ctx, query,
habitID,
from.Format("2006-01-02"),
to.Format("2006-01-02"),
)
if err != nil {
return nil, fmt.Errorf("failed to find entries: %w", err)
}
defer rows.Close()
return r.scanEntries(rows)
}
func (r *HabitEntryRepository) Update(ctx context.Context, entry *entities.HabitEntry) error {
query := `
UPDATE habit_entries
SET value = ?, completed_at = ?
WHERE id = ?
`
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.ID)
if err != nil {
return fmt.Errorf("failed to update entry: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return errors.ErrNotFound
}
return nil
}
func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEntry, error) {
var entries []*entities.HabitEntry
for rows.Next() {
var (
entry entities.HabitEntry
scheduledDate string
)
err := rows.Scan(
&entry.ID,
&entry.HabitID,
&scheduledDate,
&entry.CompletedAt,
&entry.Value,
)
if err != nil {
return nil, err
}
parsedDate, err := time.Parse("2006-01-02", scheduledDate)
if err != nil {
parsedDate, err = time.Parse(time.RFC3339, scheduledDate)
if err != nil {
return nil, fmt.Errorf("failed to parse scheduled_date: %w", err)
}
}
entry.ScheduledDate = parsedDate
entries = append(entries, &entry)
}
return entries, nil
}
func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
query := `
SELECT id, habit_id, scheduled_date, completed_at, value
FROM habit_entries
WHERE id = ?
`
var (
entry entities.HabitEntry
scheduledDate string
)
err := r.db.QueryRowContext(ctx, query, id).Scan(
&entry.ID,
&entry.HabitID,
&scheduledDate,
&entry.CompletedAt,
&entry.Value,
)
if err == sql.ErrNoRows {
return nil, errors.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to find entry: %w", err)
}
parsedDate, err := time.Parse("2006-01-02", scheduledDate)
if err != nil {
parsedDate, err = time.Parse(time.RFC3339, scheduledDate)
if err != nil {
return nil, fmt.Errorf("failed to parse scheduled_date: %w", err)
}
}
entry.ScheduledDate = parsedDate
return &entry, nil
}
func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
query := `
SELECT id, habit_id, scheduled_date, completed_at, value
FROM habit_entries
WHERE habit_id = ?
ORDER BY scheduled_date DESC
`
rows, err := r.db.QueryContext(ctx, query, habitID)
if err != nil {
return nil, fmt.Errorf("failed to find entries: %w", err)
}
defer rows.Close()
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
FROM habit_entries
WHERE habit_id = ?
AND scheduled_date < ?
ORDER BY scheduled_date DESC
`
rows, err := r.db.QueryContext(ctx, query, habitID, beforeDate.Format("2006-01-02"))
if err != nil {
return nil, fmt.Errorf("failed to find pending entries: %w", err)
}
defer rows.Close()
return r.scanEntries(rows)
}
func (r *HabitEntryRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM habit_entries WHERE id = ?`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete entry: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return errors.ErrNotFound
}
return nil
}