Files
platform/scheduling/client.go
T
mats b16820bf62 Bump go-cart-actor: scheduling cluster service + booking-hold cart/checkout seam
Deploys cmd/scheduling as a 3-replica actor cluster (shared RWX commerce-data
event logs, gRPC mesh via POD_IP + actor-pool label, edge /api/scheduling
pass-through, fail-closed mcpauth on admin/MCP). cart/checkout get
SCHEDULING_SERVICE_URL so line removal releases holds; backoffice gets
SCHEDULING_URL for the admin proxy. Platform seam: order.Line.Properties
carries booking_* fields through order.created; scheduling.booking_confirmed/
cancelled event types. Compose + prep-dev-data wire the schedules dir.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-08-26 16:42:05 +02:00

222 lines
7.0 KiB
Go

package scheduling
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// HTTPSchedulingPolicy implements SchedulingPolicy by calling the scheduling service over HTTP.
type HTTPSchedulingPolicy struct {
baseURL string
client *http.Client
}
// NewHTTPSchedulingPolicy creates a client against the scheduling HTTP endpoint (e.g. "http://scheduling:8080").
func NewHTTPSchedulingPolicy(baseURL string, client *http.Client) *HTTPSchedulingPolicy {
if client == nil {
client = &http.Client{Timeout: 5 * time.Second}
}
return &HTTPSchedulingPolicy{
baseURL: strings.TrimRight(baseURL, "/"),
client: client,
}
}
func (c *HTTPSchedulingPolicy) GetAvailability(ctx context.Context, resourceID string, from, to time.Time, durationMinutes int) ([]Slot, error) {
u := fmt.Sprintf("%s/api/scheduling/resources/%s/availability?from=%s&to=%s",
c.baseURL,
url.PathEscape(resourceID),
url.QueryEscape(from.Format(time.RFC3339)),
url.QueryEscape(to.Format(time.RFC3339)),
)
if durationMinutes > 0 {
u += "&duration=" + strconv.Itoa(durationMinutes)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("scheduling client: GetAvailability failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrResourceNotFound
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("scheduling client: unexpected status %d: %s", resp.StatusCode, string(body))
}
var slots []Slot
if err := json.NewDecoder(resp.Body).Decode(&slots); err != nil {
return nil, fmt.Errorf("scheduling client: decode slots: %w", err)
}
return slots, nil
}
func (c *HTTPSchedulingPolicy) HoldSlot(ctx context.Context, reqData HoldRequest) (HoldResult, error) {
data, err := json.Marshal(reqData)
if err != nil {
return HoldResult{}, err
}
u := fmt.Sprintf("%s/api/scheduling/holds", c.baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(data))
if err != nil {
return HoldResult{}, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return HoldResult{}, fmt.Errorf("scheduling client: HoldSlot failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusConflict {
return HoldResult{}, ErrSlotUnavailable
}
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return HoldResult{}, fmt.Errorf("scheduling client: HoldSlot status %d: %s", resp.StatusCode, string(body))
}
var res HoldResult
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return HoldResult{}, fmt.Errorf("scheduling client: decode hold result: %w", err)
}
return res, nil
}
func (c *HTTPSchedulingPolicy) ReleaseHold(ctx context.Context, holdID string) error {
u := fmt.Sprintf("%s/api/scheduling/holds/%s/release", c.baseURL, url.PathEscape(holdID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil)
if err != nil {
return err
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("scheduling client: ReleaseHold failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("scheduling client: ReleaseHold status %d: %s", resp.StatusCode, string(body))
}
return nil
}
func (c *HTTPSchedulingPolicy) ExtendHold(ctx context.Context, holdID string, extensionSeconds int) (HoldResult, error) {
payload := map[string]int{"extension_seconds": extensionSeconds}
data, _ := json.Marshal(payload)
u := fmt.Sprintf("%s/api/scheduling/holds/%s/extend", c.baseURL, url.PathEscape(holdID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(data))
if err != nil {
return HoldResult{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return HoldResult{}, fmt.Errorf("scheduling client: ExtendHold failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
return HoldResult{}, ErrHoldExpired
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return HoldResult{}, fmt.Errorf("scheduling client: ExtendHold status %d: %s", resp.StatusCode, string(body))
}
var res HoldResult
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
return HoldResult{}, fmt.Errorf("scheduling client: decode extend result: %w", err)
}
return res, nil
}
func (c *HTTPSchedulingPolicy) ConfirmBooking(ctx context.Context, holdID, orderID, customerEmail, customerName, notes string) (BookingDetails, error) {
payload := map[string]string{
"hold_id": holdID,
"order_id": orderID,
"customer_email": customerEmail,
"customer_name": customerName,
"notes": notes,
}
data, _ := json.Marshal(payload)
u := fmt.Sprintf("%s/api/scheduling/bookings", c.baseURL)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(data))
if err != nil {
return BookingDetails{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return BookingDetails{}, fmt.Errorf("scheduling client: ConfirmBooking failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusGone || resp.StatusCode == http.StatusConflict {
return BookingDetails{}, ErrHoldExpired
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
body, _ := io.ReadAll(resp.Body)
return BookingDetails{}, fmt.Errorf("scheduling client: ConfirmBooking status %d: %s", resp.StatusCode, string(body))
}
var details BookingDetails
if err := json.NewDecoder(resp.Body).Decode(&details); err != nil {
return BookingDetails{}, fmt.Errorf("scheduling client: decode booking details: %w", err)
}
return details, nil
}
func (c *HTTPSchedulingPolicy) CancelBooking(ctx context.Context, bookingID, reason string) error {
payload := map[string]string{"reason": reason}
data, _ := json.Marshal(payload)
u := fmt.Sprintf("%s/api/scheduling/bookings/%s/cancel", c.baseURL, url.PathEscape(bookingID))
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, u, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("scheduling client: CancelBooking failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return ErrBookingNotFound
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("scheduling client: CancelBooking status %d: %s", resp.StatusCode, string(body))
}
return nil
}