package travel import ( "context" "database/sql" "encoding/json" "fmt" "time" "github.com/google/uuid" ) type Repository struct { db *sql.DB } func NewRepository(db *sql.DB) *Repository { return &Repository{db: db} } func (r *Repository) InitSchema(ctx context.Context) error { query := ` CREATE TABLE IF NOT EXISTS trips ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, title VARCHAR(255) NOT NULL, destination VARCHAR(255) NOT NULL, description TEXT, cover_image TEXT, start_date TIMESTAMP NOT NULL, end_date TIMESTAMP NOT NULL, route JSONB DEFAULT '[]', flights JSONB DEFAULT '[]', hotels JSONB DEFAULT '[]', total_budget DECIMAL(12,2), currency VARCHAR(3) DEFAULT 'RUB', status VARCHAR(20) DEFAULT 'planned', ai_summary TEXT, created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_trips_user_id ON trips(user_id); CREATE INDEX IF NOT EXISTS idx_trips_status ON trips(status); CREATE INDEX IF NOT EXISTS idx_trips_start_date ON trips(start_date); CREATE TABLE IF NOT EXISTS trip_drafts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID, session_id VARCHAR(255), brief JSONB DEFAULT '{}', candidates JSONB DEFAULT '{}', selected JSONB DEFAULT '{}', phase VARCHAR(50) DEFAULT 'planning', created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_trip_drafts_user_id ON trip_drafts(user_id); CREATE INDEX IF NOT EXISTS idx_trip_drafts_session_id ON trip_drafts(session_id); CREATE TABLE IF NOT EXISTS geocode_cache ( query_hash VARCHAR(64) PRIMARY KEY, query_text TEXT NOT NULL, lat DOUBLE PRECISION NOT NULL, lng DOUBLE PRECISION NOT NULL, name TEXT, country TEXT, created_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at); ` _, err := r.db.ExecContext(ctx, query) return err } func (r *Repository) CreateTrip(ctx context.Context, trip *Trip) error { if trip.ID == "" { trip.ID = uuid.New().String() } trip.CreatedAt = time.Now() trip.UpdatedAt = time.Now() routeJSON, err := json.Marshal(trip.Route) if err != nil { return fmt.Errorf("marshal route: %w", err) } flightsJSON, err := json.Marshal(trip.Flights) if err != nil { return fmt.Errorf("marshal flights: %w", err) } hotelsJSON, err := json.Marshal(trip.Hotels) if err != nil { return fmt.Errorf("marshal hotels: %w", err) } query := ` INSERT INTO trips ( id, user_id, title, destination, description, cover_image, start_date, end_date, route, flights, hotels, total_budget, currency, status, ai_summary, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) ` _, err = r.db.ExecContext(ctx, query, trip.ID, trip.UserID, trip.Title, trip.Destination, trip.Description, trip.CoverImage, trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON, trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.CreatedAt, trip.UpdatedAt, ) return err } func (r *Repository) GetTrip(ctx context.Context, id string) (*Trip, error) { query := ` SELECT id, user_id, title, destination, description, cover_image, start_date, end_date, route, flights, hotels, total_budget, currency, status, ai_summary, created_at, updated_at FROM trips WHERE id = $1 ` var trip Trip var routeJSON, flightsJSON, hotelsJSON []byte var description, coverImage, aiSummary sql.NullString var totalBudget sql.NullFloat64 err := r.db.QueryRowContext(ctx, query, id).Scan( &trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage, &trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON, &totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if description.Valid { trip.Description = description.String } if coverImage.Valid { trip.CoverImage = coverImage.String } if aiSummary.Valid { trip.AISummary = aiSummary.String } if totalBudget.Valid { trip.TotalBudget = totalBudget.Float64 } if err := json.Unmarshal(routeJSON, &trip.Route); err != nil { return nil, fmt.Errorf("unmarshal route: %w", err) } if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil { return nil, fmt.Errorf("unmarshal flights: %w", err) } if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil { return nil, fmt.Errorf("unmarshal hotels: %w", err) } return &trip, nil } func (r *Repository) GetTripsByUser(ctx context.Context, userID string, limit, offset int) ([]Trip, error) { if limit == 0 { limit = 20 } query := ` SELECT id, user_id, title, destination, description, cover_image, start_date, end_date, route, flights, hotels, total_budget, currency, status, ai_summary, created_at, updated_at FROM trips WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 ` rows, err := r.db.QueryContext(ctx, query, userID, limit, offset) if err != nil { return nil, err } defer rows.Close() var trips []Trip for rows.Next() { var trip Trip var routeJSON, flightsJSON, hotelsJSON []byte var description, coverImage, aiSummary sql.NullString var totalBudget sql.NullFloat64 err := rows.Scan( &trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage, &trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON, &totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt, ) if err != nil { return nil, err } if description.Valid { trip.Description = description.String } if coverImage.Valid { trip.CoverImage = coverImage.String } if aiSummary.Valid { trip.AISummary = aiSummary.String } if totalBudget.Valid { trip.TotalBudget = totalBudget.Float64 } if err := json.Unmarshal(routeJSON, &trip.Route); err != nil { return nil, fmt.Errorf("unmarshal route: %w", err) } if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil { return nil, fmt.Errorf("unmarshal flights: %w", err) } if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil { return nil, fmt.Errorf("unmarshal hotels: %w", err) } trips = append(trips, trip) } return trips, nil } func (r *Repository) UpdateTrip(ctx context.Context, trip *Trip) error { trip.UpdatedAt = time.Now() routeJSON, err := json.Marshal(trip.Route) if err != nil { return fmt.Errorf("marshal route: %w", err) } flightsJSON, err := json.Marshal(trip.Flights) if err != nil { return fmt.Errorf("marshal flights: %w", err) } hotelsJSON, err := json.Marshal(trip.Hotels) if err != nil { return fmt.Errorf("marshal hotels: %w", err) } query := ` UPDATE trips SET title = $2, destination = $3, description = $4, cover_image = $5, start_date = $6, end_date = $7, route = $8, flights = $9, hotels = $10, total_budget = $11, currency = $12, status = $13, ai_summary = $14, updated_at = $15 WHERE id = $1 ` _, err = r.db.ExecContext(ctx, query, trip.ID, trip.Title, trip.Destination, trip.Description, trip.CoverImage, trip.StartDate, trip.EndDate, routeJSON, flightsJSON, hotelsJSON, trip.TotalBudget, trip.Currency, trip.Status, trip.AISummary, trip.UpdatedAt, ) return err } func (r *Repository) DeleteTrip(ctx context.Context, id string) error { _, err := r.db.ExecContext(ctx, "DELETE FROM trips WHERE id = $1", id) return err } func (r *Repository) GetTripsByStatus(ctx context.Context, userID string, status TripStatus) ([]Trip, error) { query := ` SELECT id, user_id, title, destination, description, cover_image, start_date, end_date, route, flights, hotels, total_budget, currency, status, ai_summary, created_at, updated_at FROM trips WHERE user_id = $1 AND status = $2 ORDER BY start_date ASC ` rows, err := r.db.QueryContext(ctx, query, userID, status) if err != nil { return nil, err } defer rows.Close() var trips []Trip for rows.Next() { var trip Trip var routeJSON, flightsJSON, hotelsJSON []byte var description, coverImage, aiSummary sql.NullString var totalBudget sql.NullFloat64 err := rows.Scan( &trip.ID, &trip.UserID, &trip.Title, &trip.Destination, &description, &coverImage, &trip.StartDate, &trip.EndDate, &routeJSON, &flightsJSON, &hotelsJSON, &totalBudget, &trip.Currency, &trip.Status, &aiSummary, &trip.CreatedAt, &trip.UpdatedAt, ) if err != nil { return nil, err } if description.Valid { trip.Description = description.String } if coverImage.Valid { trip.CoverImage = coverImage.String } if aiSummary.Valid { trip.AISummary = aiSummary.String } if totalBudget.Valid { trip.TotalBudget = totalBudget.Float64 } if err := json.Unmarshal(routeJSON, &trip.Route); err != nil { return nil, fmt.Errorf("unmarshal route: %w", err) } if err := json.Unmarshal(flightsJSON, &trip.Flights); err != nil { return nil, fmt.Errorf("unmarshal flights: %w", err) } if err := json.Unmarshal(hotelsJSON, &trip.Hotels); err != nil { return nil, fmt.Errorf("unmarshal hotels: %w", err) } trips = append(trips, trip) } return trips, nil } func (r *Repository) CountTripsByUser(ctx context.Context, userID string) (int, error) { var count int err := r.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM trips WHERE user_id = $1", userID).Scan(&count) return count, err } // --- Trip Draft persistence --- type TripDraft struct { ID string `json:"id"` UserID string `json:"userId"` SessionID string `json:"sessionId"` Brief json.RawMessage `json:"brief"` Candidates json.RawMessage `json:"candidates"` Selected json.RawMessage `json:"selected"` Phase string `json:"phase"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } func (r *Repository) SaveDraft(ctx context.Context, draft *TripDraft) error { if draft.ID == "" { draft.ID = uuid.New().String() } draft.UpdatedAt = time.Now() query := ` INSERT INTO trip_drafts (id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (id) DO UPDATE SET brief = EXCLUDED.brief, candidates = EXCLUDED.candidates, selected = EXCLUDED.selected, phase = EXCLUDED.phase, updated_at = EXCLUDED.updated_at ` _, err := r.db.ExecContext(ctx, query, draft.ID, draft.UserID, draft.SessionID, draft.Brief, draft.Candidates, draft.Selected, draft.Phase, draft.CreatedAt, draft.UpdatedAt, ) return err } func (r *Repository) GetDraft(ctx context.Context, id string) (*TripDraft, error) { query := ` SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at FROM trip_drafts WHERE id = $1 ` var draft TripDraft var userID sql.NullString err := r.db.QueryRowContext(ctx, query, id).Scan( &draft.ID, &userID, &draft.SessionID, &draft.Brief, &draft.Candidates, &draft.Selected, &draft.Phase, &draft.CreatedAt, &draft.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if userID.Valid { draft.UserID = userID.String } return &draft, nil } func (r *Repository) GetDraftBySession(ctx context.Context, sessionID string) (*TripDraft, error) { query := ` SELECT id, user_id, session_id, brief, candidates, selected, phase, created_at, updated_at FROM trip_drafts WHERE session_id = $1 ORDER BY updated_at DESC LIMIT 1 ` var draft TripDraft var userID sql.NullString err := r.db.QueryRowContext(ctx, query, sessionID).Scan( &draft.ID, &userID, &draft.SessionID, &draft.Brief, &draft.Candidates, &draft.Selected, &draft.Phase, &draft.CreatedAt, &draft.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if userID.Valid { draft.UserID = userID.String } return &draft, nil } func (r *Repository) DeleteDraft(ctx context.Context, id string) error { _, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE id = $1", id) return err } func (r *Repository) CleanupOldDrafts(ctx context.Context, olderThan time.Duration) error { cutoff := time.Now().Add(-olderThan) _, err := r.db.ExecContext(ctx, "DELETE FROM trip_drafts WHERE updated_at < $1", cutoff) return err } // --- Geocode cache --- type GeocodeCacheEntry struct { QueryHash string `json:"queryHash"` QueryText string `json:"queryText"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Name string `json:"name"` Country string `json:"country"` } func (r *Repository) GetCachedGeocode(ctx context.Context, queryHash string) (*GeocodeCacheEntry, error) { query := ` SELECT query_hash, query_text, lat, lng, name, country FROM geocode_cache WHERE query_hash = $1 ` var entry GeocodeCacheEntry var name, country sql.NullString err := r.db.QueryRowContext(ctx, query, queryHash).Scan( &entry.QueryHash, &entry.QueryText, &entry.Lat, &entry.Lng, &name, &country, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } if name.Valid { entry.Name = name.String } if country.Valid { entry.Country = country.String } return &entry, nil } func (r *Repository) SaveGeocodeCache(ctx context.Context, entry *GeocodeCacheEntry) error { query := ` INSERT INTO geocode_cache (query_hash, query_text, lat, lng, name, country, created_at) VALUES ($1, $2, $3, $4, $5, $6, NOW()) ON CONFLICT (query_hash) DO UPDATE SET lat = EXCLUDED.lat, lng = EXCLUDED.lng, name = EXCLUDED.name, country = EXCLUDED.country ` _, err := r.db.ExecContext(ctx, query, entry.QueryHash, entry.QueryText, entry.Lat, entry.Lng, entry.Name, entry.Country, ) return err } func (r *Repository) CleanupOldGeocodeCache(ctx context.Context, olderThan time.Duration) error { cutoff := time.Now().Add(-olderThan) _, err := r.db.ExecContext(ctx, "DELETE FROM geocode_cache WHERE created_at < $1", cutoff) return err }