package main import ( "bufio" "context" "database/sql" "fmt" "log" "os" "strconv" "strings" "time" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/logger" "github.com/gooseek/backend/internal/llm" "github.com/gooseek/backend/internal/travel" "github.com/gooseek/backend/pkg/middleware" _ "github.com/lib/pq" ) func main() { db, err := sql.Open("postgres", os.Getenv("DATABASE_URL")) if err != nil { log.Fatalf("Failed to connect to database: %v", err) } defer db.Close() if err := db.Ping(); err != nil { log.Fatalf("Failed to ping database: %v", err) } repo := travel.NewRepository(db) if err := repo.InitSchema(context.Background()); err != nil { log.Printf("Warning: Failed to init schema: %v", err) } llmCfg := llm.ProviderConfig{ ProviderID: getEnv("LLM_PROVIDER", "timeweb"), ModelKey: getEnv("LLM_MODEL", "gpt-4o-mini"), BaseURL: os.Getenv("TIMEWEB_API_BASE_URL"), APIKey: os.Getenv("TIMEWEB_API_KEY"), AgentAccessID: os.Getenv("TIMEWEB_AGENT_ACCESS_ID"), } llmBaseClient, err := llm.NewClient(llmCfg) if err != nil { log.Printf("Warning: Failed to create LLM client: %v", err) } var llmClient travel.LLMClient if llmBaseClient != nil { llmClient = travel.NewLLMClientAdapter(llmBaseClient) } useRussianAPIs := getEnv("USE_RUSSIAN_APIS", "true") == "true" svc := travel.NewService(travel.ServiceConfig{ Repository: repo, AmadeusConfig: travel.AmadeusConfig{ APIKey: os.Getenv("AMADEUS_API_KEY"), APISecret: os.Getenv("AMADEUS_API_SECRET"), BaseURL: getEnv("AMADEUS_BASE_URL", "https://test.api.amadeus.com"), }, OpenRouteConfig: travel.OpenRouteConfig{ APIKey: os.Getenv("OPENROUTE_API_KEY"), BaseURL: getEnv("OPENROUTE_BASE_URL", "https://api.openrouteservice.org"), }, TravelPayoutsConfig: travel.TravelPayoutsConfig{ Token: os.Getenv("TRAVELPAYOUTS_TOKEN"), Marker: os.Getenv("TRAVELPAYOUTS_MARKER"), }, TwoGISConfig: travel.TwoGISConfig{ APIKey: os.Getenv("TWOGIS_API_KEY"), }, LLMClient: llmClient, UseRussianAPIs: useRussianAPIs, }) app := fiber.New(fiber.Config{ ReadTimeout: 60 * time.Second, WriteTimeout: 60 * time.Second, }) app.Use(logger.New()) app.Use(cors.New()) app.Get("/health", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"status": "ok", "service": "travel-svc"}) }) jwtConfig := middleware.JWTConfig{ Secret: os.Getenv("JWT_SECRET"), AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"), } jwtOptional := middleware.JWTConfig{ Secret: os.Getenv("JWT_SECRET"), AuthSvcURL: getEnv("AUTH_SVC_URL", "http://auth-svc:3050"), AllowGuest: true, } api := app.Group("/api/v1/travel") api.Post("/plan", middleware.JWT(jwtOptional), handlePlanTrip(svc)) api.Get("/trips", middleware.JWT(jwtOptional), handleGetTrips(svc)) api.Post("/trips", middleware.JWT(jwtConfig), handleCreateTrip(svc)) api.Get("/trips/:id", middleware.JWT(jwtOptional), handleGetTrip(svc)) api.Put("/trips/:id", middleware.JWT(jwtConfig), handleUpdateTrip(svc)) api.Delete("/trips/:id", middleware.JWT(jwtConfig), handleDeleteTrip(svc)) api.Get("/trips/upcoming", middleware.JWT(jwtOptional), handleGetUpcoming(svc)) api.Get("/flights", middleware.JWT(jwtOptional), handleSearchFlights(svc)) api.Post("/flights", middleware.JWT(jwtOptional), handleSearchFlightsPost(svc)) api.Get("/hotels", middleware.JWT(jwtOptional), handleSearchHotels(svc)) api.Post("/hotels", middleware.JWT(jwtOptional), handleSearchHotelsPost(svc)) api.Post("/route", middleware.JWT(jwtOptional), handleGetRoute(svc)) api.Get("/geocode", middleware.JWT(jwtOptional), handleGeocode(svc)) api.Get("/poi", middleware.JWT(jwtOptional), handleSearchPOI(svc)) api.Post("/poi", middleware.JWT(jwtOptional), handleSearchPOIPost(svc)) api.Post("/places", middleware.JWT(jwtOptional), handleSearchPlaces(svc)) port := getEnvInt("PORT", 3035) log.Printf("travel-svc listening on :%d", port) log.Fatal(app.Listen(fmt.Sprintf(":%d", port))) } func handlePlanTrip(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { var req travel.TravelPlanRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } c.Set("Content-Type", "application/x-ndjson") c.Set("Cache-Control", "no-cache") c.Set("Transfer-Encoding", "chunked") c.Context().SetBodyStreamWriter(func(w *bufio.Writer) { ctx := context.Background() if err := svc.PlanTrip(ctx, req, w); err != nil { log.Printf("Plan trip error: %v", err) } }) return nil } } func handleGetTrips(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { userID, _ := c.Locals("userId").(string) if userID == "" { return c.JSON(fiber.Map{"trips": []interface{}{}}) } limit := c.QueryInt("limit", 20) offset := c.QueryInt("offset", 0) trips, err := svc.GetUserTrips(c.Context(), userID, limit, offset) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"trips": trips}) } } func handleCreateTrip(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { userID := c.Locals("userId").(string) var trip travel.Trip if err := c.BodyParser(&trip); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } trip.UserID = userID if trip.Currency == "" { trip.Currency = "RUB" } if trip.Status == "" { trip.Status = travel.TripStatusPlanned } if err := svc.CreateTrip(c.Context(), &trip); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.Status(201).JSON(trip) } } func handleGetTrip(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { id := c.Params("id") trip, err := svc.GetTrip(c.Context(), id) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } if trip == nil { return c.Status(404).JSON(fiber.Map{"error": "Trip not found"}) } return c.JSON(trip) } } func handleUpdateTrip(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { id := c.Params("id") userID := c.Locals("userId").(string) existing, err := svc.GetTrip(c.Context(), id) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } if existing == nil { return c.Status(404).JSON(fiber.Map{"error": "Trip not found"}) } if existing.UserID != userID { return c.Status(403).JSON(fiber.Map{"error": "Access denied"}) } var trip travel.Trip if err := c.BodyParser(&trip); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } trip.ID = id trip.UserID = userID if err := svc.UpdateTrip(c.Context(), &trip); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(trip) } } func handleDeleteTrip(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { id := c.Params("id") userID := c.Locals("userId").(string) existing, err := svc.GetTrip(c.Context(), id) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } if existing == nil { return c.Status(404).JSON(fiber.Map{"error": "Trip not found"}) } if existing.UserID != userID { return c.Status(403).JSON(fiber.Map{"error": "Access denied"}) } if err := svc.DeleteTrip(c.Context(), id); err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"success": true}) } } func handleGetUpcoming(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { userID, _ := c.Locals("userId").(string) if userID == "" { return c.JSON(fiber.Map{"trips": []interface{}{}}) } trips, err := svc.GetUpcomingTrips(c.Context(), userID) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"trips": trips}) } } func handleSearchFlights(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { req := travel.FlightSearchRequest{ Origin: c.Query("origin"), Destination: c.Query("destination"), DepartureDate: c.Query("departureDate"), ReturnDate: c.Query("returnDate"), Adults: c.QueryInt("adults", 1), Children: c.QueryInt("children", 0), CabinClass: c.Query("cabinClass"), MaxPrice: c.QueryInt("maxPrice", 0), Currency: c.Query("currency", "RUB"), } if req.Origin == "" || req.Destination == "" || req.DepartureDate == "" { return c.Status(400).JSON(fiber.Map{"error": "origin, destination, departureDate required"}) } flights, err := svc.SearchFlights(c.Context(), req) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"flights": flights}) } } func handleSearchHotels(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { lat, _ := strconv.ParseFloat(c.Query("lat"), 64) lng, _ := strconv.ParseFloat(c.Query("lng"), 64) req := travel.HotelSearchRequest{ CityCode: c.Query("cityCode"), Lat: lat, Lng: lng, Radius: c.QueryInt("radius", 5), CheckIn: c.Query("checkIn"), CheckOut: c.Query("checkOut"), Adults: c.QueryInt("adults", 1), Rooms: c.QueryInt("rooms", 1), MaxPrice: c.QueryInt("maxPrice", 0), Currency: c.Query("currency", "RUB"), Rating: c.QueryInt("rating", 0), } if req.CityCode == "" || req.CheckIn == "" || req.CheckOut == "" { return c.Status(400).JSON(fiber.Map{"error": "cityCode, checkIn, checkOut required"}) } hotels, err := svc.SearchHotels(c.Context(), req) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"hotels": hotels}) } } func handleGetRoute(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { var req struct { Points []travel.GeoLocation `json:"points"` Profile string `json:"profile"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request"}) } if len(req.Points) < 2 { return c.Status(400).JSON(fiber.Map{"error": "At least 2 points required"}) } route, err := svc.GetRoute(c.Context(), req.Points, req.Profile) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(route) } } func handleGeocode(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { query := c.Query("query") if query == "" { query = c.Query("q") } if query == "" { return c.Status(400).JSON(fiber.Map{"error": "query parameter required"}) } location, err := svc.Geocode(c.Context(), query) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(location) } } func handleSearchPOI(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { lat, _ := strconv.ParseFloat(c.Query("lat"), 64) lng, _ := strconv.ParseFloat(c.Query("lng"), 64) if lat == 0 || lng == 0 { return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"}) } req := travel.POISearchRequest{ Lat: lat, Lng: lng, Radius: c.QueryInt("radius", 1000), Limit: c.QueryInt("limit", 20), } if categories := c.Query("categories"); categories != "" { req.Categories = strings.Split(categories, ",") } pois, err := svc.SearchPOI(c.Context(), req) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(fiber.Map{"pois": pois}) } } func handleSearchFlightsPost(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { var req travel.FlightSearchRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.Origin == "" || req.Destination == "" || req.DepartureDate == "" { return c.Status(400).JSON(fiber.Map{"error": "origin, destination, departureDate required"}) } if req.Adults == 0 { req.Adults = 1 } if req.Currency == "" { req.Currency = "RUB" } flights, err := svc.SearchFlights(c.Context(), req) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(flights) } } func handleSearchHotelsPost(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { var req travel.HotelSearchRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.CheckIn == "" || req.CheckOut == "" { return c.Status(400).JSON(fiber.Map{"error": "checkIn, checkOut required"}) } if req.Adults == 0 { req.Adults = 1 } if req.Rooms == 0 { req.Rooms = 1 } if req.Currency == "" { req.Currency = "RUB" } hotels, err := svc.SearchHotels(c.Context(), req) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(hotels) } } func handleSearchPOIPost(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { var req travel.POISearchRequest if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.Lat == 0 || req.Lng == 0 { return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"}) } if req.Radius == 0 { req.Radius = 1000 } if req.Limit == 0 { req.Limit = 20 } pois, err := svc.SearchPOI(c.Context(), req) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(pois) } } func handleSearchPlaces(svc *travel.Service) fiber.Handler { return func(c *fiber.Ctx) error { var req struct { Query string `json:"query"` Lat float64 `json:"lat"` Lng float64 `json:"lng"` Radius int `json:"radius"` } if err := c.BodyParser(&req); err != nil { return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"}) } if req.Query == "" { return c.Status(400).JSON(fiber.Map{"error": "query required"}) } if req.Lat == 0 || req.Lng == 0 { return c.Status(400).JSON(fiber.Map{"error": "lat, lng required"}) } if req.Radius == 0 { req.Radius = 5000 } places, err := svc.SearchPlaces(c.Context(), req.Query, req.Lat, req.Lng, req.Radius) if err != nil { return c.Status(500).JSON(fiber.Map{"error": err.Error()}) } return c.JSON(places) } } func getEnv(key, defaultValue string) string { if val := os.Getenv(key); val != "" { return val } return defaultValue } func getEnvInt(key string, defaultValue int) int { if val := os.Getenv(key); val != "" { if i, err := strconv.Atoi(val); err == nil { return i } } return defaultValue }