Add MCP server
This commit is contained in:
221
servers/mcp/README.md
Normal file
221
servers/mcp/README.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# MCP Server for PARCOURSMOB
|
||||
|
||||
This package implements a Model Context Protocol (MCP) HTTP server for the PARCOURSMOB application, exposing journey search functionality as an MCP tool.
|
||||
|
||||
## Overview
|
||||
|
||||
The MCP server provides a standardized interface for AI assistants to search for multimodal journeys, including:
|
||||
- Public transit routes
|
||||
- Carpooling solutions (via operators like Mobicoop)
|
||||
- Solidarity transport
|
||||
- Organized carpools
|
||||
- Fleet vehicles
|
||||
- Local knowledge base solutions
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable the MCP server in your config file:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
mcp:
|
||||
enabled: true
|
||||
listen: "0.0.0.0:8081"
|
||||
```
|
||||
|
||||
Or via environment variables:
|
||||
```bash
|
||||
export SERVER_MCP_ENABLED=true
|
||||
export SERVER_MCP_LISTEN="0.0.0.0:8081"
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Initialize
|
||||
```http
|
||||
POST /mcp/v1/initialize
|
||||
```
|
||||
|
||||
Returns server capabilities and protocol version.
|
||||
|
||||
### List Tools
|
||||
```http
|
||||
GET /mcp/v1/tools/list
|
||||
```
|
||||
|
||||
Returns available MCP tools.
|
||||
|
||||
### Call Tool
|
||||
```http
|
||||
POST /mcp/v1/tools/call
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "search_journeys",
|
||||
"arguments": {
|
||||
"departure": "123 Main St, Paris, France",
|
||||
"destination": "456 Oak Ave, Lyon, France",
|
||||
"departure_date": "2025-01-20",
|
||||
"departure_time": "14:30"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Health Check
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### search_journeys
|
||||
|
||||
Searches for multimodal journey options between two locations.
|
||||
|
||||
**Parameters:**
|
||||
- `departure` (string, required): Departure address as text
|
||||
- `destination` (string, required): Destination address as text
|
||||
- `departure_date` (string, required): Date in YYYY-MM-DD format
|
||||
- `departure_time` (string, required): Time in HH:MM format (24-hour)
|
||||
- `passenger_id` (string, optional): Passenger ID to retrieve address from account
|
||||
- `exclude_driver_ids` (array, optional): List of driver IDs to exclude from solidarity transport results
|
||||
|
||||
**Example Request:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8081/mcp/v1/tools/call \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "search_journeys",
|
||||
"arguments": {
|
||||
"departure": "Gare de Lyon, Paris",
|
||||
"destination": "Part-Dieu, Lyon",
|
||||
"departure_date": "2025-01-20",
|
||||
"departure_time": "09:00"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Response Format:**
|
||||
```json
|
||||
{
|
||||
"search_parameters": {
|
||||
"departure": {
|
||||
"label": "Gare de Lyon, Paris, France",
|
||||
"coordinates": { "type": "Point", "coordinates": [2.3736, 48.8443] }
|
||||
},
|
||||
"destination": {
|
||||
"label": "Part-Dieu, Lyon, France",
|
||||
"coordinates": { "type": "Point", "coordinates": [4.8575, 45.7605] }
|
||||
},
|
||||
"departure_date": "2025-01-20",
|
||||
"departure_time": "09:00"
|
||||
},
|
||||
"results": {
|
||||
"CarpoolResults": [...],
|
||||
"TransitResults": [...],
|
||||
"VehicleResults": [...],
|
||||
"DriverJourneys": [...],
|
||||
"OrganizedCarpools": [...],
|
||||
"KnowledgeBaseResults": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Package Structure
|
||||
|
||||
- `mcp.go`: HTTP server and request routing
|
||||
- `tools.go`: Tool registration and execution
|
||||
- `journey_search.go`: Journey search tool implementation
|
||||
|
||||
### Flow
|
||||
|
||||
1. HTTP request received at MCP endpoint
|
||||
2. Tool name and arguments extracted
|
||||
3. Addresses geocoded using Pelias geocoding service
|
||||
4. Journey search executed via ApplicationHandler
|
||||
5. Results formatted and returned as JSON
|
||||
|
||||
### Dependencies
|
||||
|
||||
The MCP server uses:
|
||||
- Pelias geocoding service (configured via `geo.pelias.url`)
|
||||
- ApplicationHandler for journey search business logic
|
||||
- All backend services (GRPC): solidarity transport, carpool service, transit routing, fleets, etc.
|
||||
|
||||
## Integration with AI Assistants
|
||||
|
||||
The MCP server follows the Model Context Protocol specification, making it compatible with AI assistants that support MCP, such as Claude Desktop or other MCP-enabled tools.
|
||||
|
||||
Example Claude Desktop configuration:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"parcoursmob": {
|
||||
"url": "http://localhost:8081/mcp/v1"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Testing
|
||||
|
||||
Test the health endpoint:
|
||||
```bash
|
||||
curl http://localhost:8081/health
|
||||
```
|
||||
|
||||
List available tools:
|
||||
```bash
|
||||
curl http://localhost:8081/mcp/v1/tools/list
|
||||
```
|
||||
|
||||
Test journey search:
|
||||
```bash
|
||||
curl -X POST http://localhost:8081/mcp/v1/tools/call \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "search_journeys",
|
||||
"arguments": {
|
||||
"departure": "Paris",
|
||||
"destination": "Lyon",
|
||||
"departure_date": "2025-01-20",
|
||||
"departure_time": "10:00"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Adding New Tools
|
||||
|
||||
To add a new MCP tool:
|
||||
|
||||
1. Define the tool in `tools.go`:
|
||||
```go
|
||||
func (h *ToolsHandler) registerNewTool() {
|
||||
tool := &Tool{
|
||||
Name: "tool_name",
|
||||
Description: "Tool description",
|
||||
InputSchema: map[string]any{...},
|
||||
}
|
||||
h.tools["tool_name"] = tool
|
||||
}
|
||||
```
|
||||
|
||||
2. Implement the handler:
|
||||
```go
|
||||
func (h *ToolsHandler) handleNewTool(ctx context.Context, arguments map[string]any) (any, error) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
3. Add to CallTool switch statement in `tools.go`
|
||||
|
||||
## Notes
|
||||
|
||||
- All times are handled in Europe/Paris timezone
|
||||
- Geocoding uses the first result from Pelias
|
||||
- Journey search runs multiple transport mode queries in parallel
|
||||
- Results include all available transport options for the requested route
|
||||
158
servers/mcp/journey_tool.go
Normal file
158
servers/mcp/journey_tool.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/core/application"
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/paulmach/orb/geojson"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// JourneySearchInput represents the input for journey search
|
||||
type JourneySearchInput struct {
|
||||
Departure string `json:"departure" jsonschema:"Departure address as text (e.g. Paris or Gare de Lyon Paris)"`
|
||||
Destination string `json:"destination" jsonschema:"Destination address as text (e.g. Lyon or Part-Dieu Lyon)"`
|
||||
DepartureDate string `json:"departure_date" jsonschema:"Departure date in YYYY-MM-DD format"`
|
||||
DepartureTime string `json:"departure_time" jsonschema:"Departure time in HH:MM format (24-hour)"`
|
||||
PassengerID string `json:"passenger_id,omitempty" jsonschema:"Optional passenger ID to retrieve address from account"`
|
||||
ExcludeDriverIDs []string `json:"exclude_driver_ids,omitempty" jsonschema:"Optional list of driver IDs to exclude from solidarity transport results"`
|
||||
}
|
||||
|
||||
// JourneySearchOutput represents the output of journey search
|
||||
type JourneySearchOutput struct {
|
||||
SearchParameters map[string]any `json:"search_parameters"`
|
||||
Results any `json:"results"`
|
||||
}
|
||||
|
||||
// registerJourneySearchTool registers the journey search tool with the MCP server
|
||||
func (s *MCPServer) registerJourneySearchTool() {
|
||||
mcpsdk.AddTool(
|
||||
s.mcpServer,
|
||||
&mcpsdk.Tool{
|
||||
Name: "search_journeys",
|
||||
Description: "Search for multimodal journeys including transit, carpooling, solidarity transport, organized carpool, and local solutions. Accepts departure and destination as text addresses that will be geocoded automatically.",
|
||||
},
|
||||
s.handleJourneySearch,
|
||||
)
|
||||
}
|
||||
|
||||
// handleJourneySearch handles the journey search tool execution
|
||||
func (s *MCPServer) handleJourneySearch(ctx context.Context, req *mcpsdk.CallToolRequest, input JourneySearchInput) (*mcpsdk.CallToolResult, *JourneySearchOutput, error) {
|
||||
// Geocode departure address using French government API
|
||||
log.Info().Str("address", input.Departure).Msg("Geocoding departure address")
|
||||
departureFeature, err := s.geocodeAddress(input.Departure)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to geocode departure address")
|
||||
return nil, nil, fmt.Errorf("failed to geocode departure address: %w", err)
|
||||
}
|
||||
|
||||
// Geocode destination address using French government API
|
||||
log.Info().Str("address", input.Destination).Msg("Geocoding destination address")
|
||||
destinationFeature, err := s.geocodeAddress(input.Destination)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to geocode destination address")
|
||||
return nil, nil, fmt.Errorf("failed to geocode destination address: %w", err)
|
||||
}
|
||||
|
||||
// Parse date and time
|
||||
parisLoc, err := time.LoadLocation("Europe/Paris")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to load Paris timezone: %w", err)
|
||||
}
|
||||
|
||||
departureDateTime, err := time.ParseInLocation("2006-01-02 15:04", fmt.Sprintf("%s %s", input.DepartureDate, input.DepartureTime), parisLoc)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to parse date/time")
|
||||
return nil, nil, fmt.Errorf("failed to parse departure date/time: %w", err)
|
||||
}
|
||||
|
||||
// Convert to UTC for the search
|
||||
departureDateTime = departureDateTime.UTC()
|
||||
|
||||
log.Info().
|
||||
Str("departure", input.Departure).
|
||||
Str("destination", input.Destination).
|
||||
Time("departure_datetime", departureDateTime).
|
||||
Msg("Executing journey search")
|
||||
|
||||
// Prepare exclude driver ID (only first one if provided)
|
||||
excludeDriverID := ""
|
||||
if len(input.ExcludeDriverIDs) > 0 {
|
||||
excludeDriverID = input.ExcludeDriverIDs[0]
|
||||
}
|
||||
|
||||
// Prepare search options - disable transit for MCP requests
|
||||
searchOptions := &application.SearchJourneyOptions{
|
||||
DisableTransit: true,
|
||||
}
|
||||
|
||||
// Call the journey search from application handler
|
||||
searchResult, err := s.applicationHandler.SearchJourneys(
|
||||
ctx,
|
||||
departureDateTime,
|
||||
departureFeature,
|
||||
destinationFeature,
|
||||
input.PassengerID,
|
||||
excludeDriverID,
|
||||
"", // solidarityExcludeGroupId - not used in MCP context
|
||||
searchOptions,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("journey search failed: %w", err)
|
||||
}
|
||||
|
||||
// Format the results for MCP response
|
||||
response := &JourneySearchOutput{
|
||||
SearchParameters: map[string]any{
|
||||
"departure": map[string]any{
|
||||
"label": getFeatureLabel(departureFeature),
|
||||
"coordinates": departureFeature.Geometry,
|
||||
},
|
||||
"destination": map[string]any{
|
||||
"label": getFeatureLabel(destinationFeature),
|
||||
"coordinates": destinationFeature.Geometry,
|
||||
},
|
||||
"departure_date": input.DepartureDate,
|
||||
"departure_time": input.DepartureTime,
|
||||
},
|
||||
Results: searchResult,
|
||||
}
|
||||
|
||||
return &mcpsdk.CallToolResult{}, response, nil
|
||||
}
|
||||
|
||||
// geocodeAddress uses the geo service helper to geocode an address
|
||||
func (s *MCPServer) geocodeAddress(address string) (*geojson.Feature, error) {
|
||||
// Use the geo service to get autocomplete results
|
||||
featureCollection, err := s.geoService.Autocomplete(address)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("geocoding request failed: %w", err)
|
||||
}
|
||||
|
||||
if len(featureCollection.Features) == 0 {
|
||||
return nil, fmt.Errorf("no results found for address: %s", address)
|
||||
}
|
||||
|
||||
// Return the first feature directly
|
||||
return featureCollection.Features[0], nil
|
||||
}
|
||||
|
||||
// getFeatureLabel extracts a human-readable label from a GeoJSON Feature
|
||||
func getFeatureLabel(feature *geojson.Feature) string {
|
||||
if feature.Properties == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Try common label fields
|
||||
if label, ok := feature.Properties["label"].(string); ok {
|
||||
return label
|
||||
}
|
||||
if name, ok := feature.Properties["name"].(string); ok {
|
||||
return name
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
109
servers/mcp/mcp.go
Normal file
109
servers/mcp/mcp.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/core/application"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/core/utils/geo"
|
||||
cache "git.coopgo.io/coopgo-apps/parcoursmob/core/utils/storage"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/services"
|
||||
)
|
||||
|
||||
// MCPServer represents the MCP HTTP server
|
||||
type MCPServer struct {
|
||||
cfg *viper.Viper
|
||||
services *services.ServicesHandler
|
||||
kv cache.KVHandler
|
||||
filestorage cache.FileStorage
|
||||
applicationHandler *application.ApplicationHandler
|
||||
mcpServer *mcpsdk.Server
|
||||
geoService *geo.GeoService
|
||||
}
|
||||
|
||||
// NewMCPServer creates a new MCP server instance
|
||||
func NewMCPServer(
|
||||
cfg *viper.Viper,
|
||||
svc *services.ServicesHandler,
|
||||
applicationHandler *application.ApplicationHandler,
|
||||
kv cache.KVHandler,
|
||||
filestorage cache.FileStorage,
|
||||
) *MCPServer {
|
||||
// Initialize geocoding service
|
||||
geoType := cfg.GetString("geo.type")
|
||||
baseURL := cfg.GetString("geo." + geoType + ".url")
|
||||
autocompleteEndpoint := cfg.GetString("geo." + geoType + ".autocomplete")
|
||||
geoService := geo.NewGeoService(geoType, baseURL, autocompleteEndpoint)
|
||||
|
||||
server := &MCPServer{
|
||||
cfg: cfg,
|
||||
services: svc,
|
||||
kv: kv,
|
||||
filestorage: filestorage,
|
||||
applicationHandler: applicationHandler,
|
||||
geoService: geoService,
|
||||
}
|
||||
|
||||
// Create MCP server with implementation info
|
||||
server.mcpServer = mcpsdk.NewServer(&mcpsdk.Implementation{
|
||||
Name: "parcoursmob-mcp-server",
|
||||
Version: "1.0.0",
|
||||
}, nil)
|
||||
|
||||
// Register journey search tool
|
||||
server.registerJourneySearchTool()
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
// Run starts the MCP HTTP server with SSE transport
|
||||
func Run(
|
||||
cfg *viper.Viper,
|
||||
svc *services.ServicesHandler,
|
||||
applicationHandler *application.ApplicationHandler,
|
||||
kv cache.KVHandler,
|
||||
filestorage cache.FileStorage,
|
||||
) {
|
||||
address := cfg.GetString("server.mcp.listen")
|
||||
service_name := cfg.GetString("service_name")
|
||||
|
||||
mcpServer := NewMCPServer(cfg, svc, applicationHandler, kv, filestorage)
|
||||
|
||||
// Create HTTP server with SSE transport
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health check endpoint
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"healthy"}`))
|
||||
})
|
||||
|
||||
// MCP Streamable HTTP endpoint (preferred over SSE as of 2025-03-26 spec)
|
||||
streamHandler := mcpsdk.NewStreamableHTTPHandler(func(r *http.Request) *mcpsdk.Server {
|
||||
return mcpServer.mcpServer
|
||||
}, nil)
|
||||
mux.Handle("/", streamHandler)
|
||||
|
||||
// Also support legacy SSE endpoint for backwards compatibility
|
||||
sseHandler := mcpsdk.NewSSEHandler(func(r *http.Request) *mcpsdk.Server {
|
||||
return mcpServer.mcpServer
|
||||
}, nil)
|
||||
mux.Handle("/sse", sseHandler)
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: mux,
|
||||
Addr: address,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
log.Info().Str("service_name", service_name).Str("address", address).Msg("Running MCP HTTP server with SSE transport")
|
||||
|
||||
err := srv.ListenAndServe()
|
||||
log.Error().Err(err).Msg("MCP server error")
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -14,13 +13,13 @@ func (h *Handler) GeoAutocomplete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
text := t[0]
|
||||
|
||||
result, err := h.geoService.Autocomplete(text)
|
||||
featureCollection, err := h.geoService.Autocomplete(text)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
j, err := json.Marshal(result.Features)
|
||||
j, err := featureCollection.MarshalJSON()
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -21,7 +21,12 @@ type Handler struct {
|
||||
|
||||
func NewHandler(cfg *viper.Viper, idp *identification.IdentificationProvider, appHandler *application.ApplicationHandler, cacheHandler cacheStorage.CacheHandler) *Handler {
|
||||
cacheService := cache.NewCacheService(cacheHandler)
|
||||
geoService := geo.NewGeoService(cfg.GetString("geo.pelias.url"))
|
||||
|
||||
// Get geocoding configuration
|
||||
geoType := cfg.GetString("geo.type")
|
||||
baseURL := cfg.GetString("geo." + geoType + ".url")
|
||||
autocompleteEndpoint := cfg.GetString("geo." + geoType + ".autocomplete")
|
||||
geoService := geo.NewGeoService(geoType, baseURL, autocompleteEndpoint)
|
||||
|
||||
return &Handler{
|
||||
config: cfg,
|
||||
|
||||
@@ -27,4 +27,5 @@ func (ws *WebServer) setupSolidarityTransportRoutes(appRouter *mux.Router) {
|
||||
solidarityTransport.HandleFunc("/bookings/{bookingid}/confirm", ws.appHandler.SolidarityTransportBookingStatusHTTPHandler("confirm"))
|
||||
solidarityTransport.HandleFunc("/bookings/{bookingid}/cancel", ws.appHandler.SolidarityTransportBookingStatusHTTPHandler("cancel"))
|
||||
solidarityTransport.HandleFunc("/bookings/{bookingid}/waitconfirmation", ws.appHandler.SolidarityTransportBookingStatusHTTPHandler("waitconfirmation"))
|
||||
solidarityTransport.HandleFunc("/bookings/{bookingid}/create-replacement", ws.appHandler.SolidarityTransportCreateReplacementBookingHTTPHandler())
|
||||
}
|
||||
@@ -105,6 +105,8 @@ func (h *Handler) JourneysSearchHTTPHandler() http.HandlerFunc {
|
||||
destinationGeo,
|
||||
passengerID,
|
||||
solidarityTransportExcludeDriver,
|
||||
"", // solidarityExcludeGroupId - for modal search replacement only
|
||||
nil, // options - use defaults
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error in journey search")
|
||||
@@ -389,6 +391,8 @@ func (h *Handler) JourneysSearchCompactHTTPHandler() http.HandlerFunc {
|
||||
destinationGeo,
|
||||
passengerID,
|
||||
solidarityTransportExcludeDriver,
|
||||
"", // solidarityExcludeGroupId - for modal search replacement only
|
||||
nil, // options - use defaults
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error in journey search")
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"time"
|
||||
|
||||
groupstorage "git.coopgo.io/coopgo-platform/groups-management/storage"
|
||||
mobilityaccountsstorage "git.coopgo.io/coopgo-platform/mobility-accounts/storage"
|
||||
gen "git.coopgo.io/coopgo-platform/solidarity-transport/servers/grpc/proto/gen"
|
||||
"git.coopgo.io/coopgo-apps/parcoursmob/core/utils/identification"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/zerolog/log"
|
||||
@@ -520,6 +522,7 @@ func (h *Handler) SolidarityTransportDriverJourneyHTTPHandler() http.HandlerFunc
|
||||
driverID := vars["driverid"]
|
||||
journeyID := vars["journeyid"]
|
||||
passengerID := r.URL.Query().Get("passengerid")
|
||||
replacesBookingID := r.URL.Query().Get("replaces_booking_id")
|
||||
|
||||
if r.Method == "POST" {
|
||||
// Parse form data
|
||||
@@ -531,7 +534,8 @@ func (h *Handler) SolidarityTransportDriverJourneyHTTPHandler() http.HandlerFunc
|
||||
fmt.Sscanf(r.PostFormValue("return_waiting_time"), "%d", &returnWaitingTimeMinutes)
|
||||
}
|
||||
|
||||
bookingID, err := h.applicationHandler.CreateSolidarityTransportJourneyBooking(r.Context(), driverID, journeyID, passengerID, motivation, message, doNotSend, returnWaitingTimeMinutes)
|
||||
replacesBookingID := r.PostFormValue("replaces_booking_id")
|
||||
bookingID, err := h.applicationHandler.CreateSolidarityTransportJourneyBooking(r.Context(), driverID, journeyID, passengerID, motivation, message, doNotSend, returnWaitingTimeMinutes, replacesBookingID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error creating solidarity transport journey booking")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
@@ -559,7 +563,7 @@ func (h *Handler) SolidarityTransportDriverJourneyHTTPHandler() http.HandlerFunc
|
||||
return
|
||||
}
|
||||
|
||||
h.renderer.SolidarityTransportDriverJourney(w, r, result.Journey, result.Driver, result.Passenger, result.Beneficiaries, result.PassengerWalletBalance, result.PricingResult)
|
||||
h.renderer.SolidarityTransportDriverJourney(w, r, result.Journey, result.Driver, result.Passenger, result.Beneficiaries, result.PassengerWalletBalance, result.PricingResult, replacesBookingID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,7 +596,141 @@ func (h *Handler) SolidarityTransportBookingDisplayHTTPHandler() http.HandlerFun
|
||||
return
|
||||
}
|
||||
|
||||
h.renderer.SolidarityTransportBookingDisplay(w, r, result.Booking, result.Driver, result.Passenger, result.PassengerWalletBalance)
|
||||
// If booking is cancelled, search for replacement drivers
|
||||
var replacementDrivers any
|
||||
var replacementDriversMap map[string]mobilityaccountsstorage.Account
|
||||
var replacementPricing map[string]map[string]interface{}
|
||||
var replacementLocations map[string]string
|
||||
if result.Booking.Status == "CANCELLED" {
|
||||
// Initialize maps to avoid nil pointer in template
|
||||
replacementDriversMap = make(map[string]mobilityaccountsstorage.Account)
|
||||
replacementPricing = make(map[string]map[string]interface{})
|
||||
replacementLocations = make(map[string]string)
|
||||
|
||||
searchResult, err := h.applicationHandler.SearchJourneys(
|
||||
r.Context(),
|
||||
result.Booking.Journey.PassengerPickupDate,
|
||||
result.Booking.Journey.PassengerPickup,
|
||||
result.Booking.Journey.PassengerDrop,
|
||||
result.Booking.PassengerId,
|
||||
result.Booking.DriverId, // Exclude the original driver
|
||||
result.Booking.GroupId, // Exclude drivers with bookings in this group
|
||||
nil, // options - use defaults
|
||||
)
|
||||
if err == nil {
|
||||
replacementDrivers = searchResult.DriverJourneys
|
||||
replacementDriversMap = searchResult.Drivers
|
||||
|
||||
// Calculate pricing for each replacement driver journey
|
||||
for _, dj := range searchResult.DriverJourneys {
|
||||
// Extract driver departure location
|
||||
if dj.DriverDeparture != nil && dj.DriverDeparture.Serialized != "" {
|
||||
var feature map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(dj.DriverDeparture.Serialized), &feature); err == nil {
|
||||
if props, ok := feature["properties"].(map[string]interface{}); ok {
|
||||
if name, ok := props["name"].(string); ok {
|
||||
replacementLocations[dj.Id] = name
|
||||
} else if label, ok := props["label"].(string); ok {
|
||||
replacementLocations[dj.Id] = label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pricingResult, err := h.applicationHandler.CalculateSolidarityTransportPricing(r.Context(), dj, result.Booking.PassengerId)
|
||||
if err == nil {
|
||||
pricing := map[string]interface{}{
|
||||
"passenger": map[string]interface{}{
|
||||
"amount": pricingResult["passenger"].Amount,
|
||||
"currency": pricingResult["passenger"].Currency,
|
||||
},
|
||||
"driver": map[string]interface{}{
|
||||
"amount": pricingResult["driver"].Amount,
|
||||
"currency": pricingResult["driver"].Currency,
|
||||
},
|
||||
}
|
||||
replacementPricing[dj.Id] = pricing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.renderer.SolidarityTransportBookingDisplay(w, r, result.Booking, result.Driver, result.Passenger, result.PassengerWalletBalance, replacementDrivers, replacementDriversMap, replacementPricing, replacementLocations)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) SolidarityTransportCreateReplacementBookingHTTPHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
oldBookingID := vars["bookingid"]
|
||||
|
||||
// Get the old booking to retrieve its data
|
||||
oldBookingResult, err := h.applicationHandler.GetSolidarityTransportBookingData(r.Context(), oldBookingID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error retrieving old booking")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse form data for new driver/journey
|
||||
driverID := r.PostFormValue("driver_id")
|
||||
journeyID := r.PostFormValue("journey_id")
|
||||
message := r.PostFormValue("message")
|
||||
doNotSend := r.PostFormValue("do_not_send") == "on"
|
||||
|
||||
// Use old booking's data
|
||||
passengerID := oldBookingResult.Booking.PassengerId
|
||||
motivation := ""
|
||||
if oldBookingResult.Booking.Data != nil {
|
||||
if m, ok := oldBookingResult.Booking.Data["motivation"].(string); ok {
|
||||
motivation = m
|
||||
}
|
||||
}
|
||||
|
||||
// Get the new driver journey to retrieve journey information
|
||||
driverJourneyResp, err := h.services.GRPC.SolidarityTransport.GetDriverJourney(r.Context(), &gen.GetDriverJourneyRequest{
|
||||
DriverId: driverID,
|
||||
JourneyId: journeyID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error retrieving new driver journey")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate return waiting time based on journey type
|
||||
returnWaitingTimeMinutes := 30 // Default for round trips
|
||||
if driverJourneyResp.DriverJourney.Noreturn {
|
||||
returnWaitingTimeMinutes = 0
|
||||
}
|
||||
|
||||
// Create the replacement booking with pricing calculated in CreateSolidarityTransportJourneyBooking
|
||||
bookingID, err := h.applicationHandler.CreateSolidarityTransportJourneyBooking(
|
||||
r.Context(),
|
||||
driverID,
|
||||
journeyID,
|
||||
passengerID,
|
||||
motivation,
|
||||
message, // message from form
|
||||
doNotSend, // doNotSend from form checkbox
|
||||
returnWaitingTimeMinutes,
|
||||
oldBookingID,
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("error creating replacement booking")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Str("booking_id", bookingID).Str("replaces", oldBookingID).Msg("Replacement booking created successfully")
|
||||
|
||||
// Redirect to the new booking
|
||||
http.Redirect(w, r, fmt.Sprintf("/app/solidarity-transport/bookings/%s", bookingID), http.StatusFound)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user