Files
go-cart-actor/internal/ucp/customer.go
T
2026-07-01 10:40:28 +02:00

557 lines
16 KiB
Go

package ucp
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
messages "git.k6n.net/mats/go-cart-actor/proto/profile"
)
// CustomerServer is the UCP REST adapter over the profile grain pool.
type CustomerServer struct {
applier ProfileApplier
deleter CredentialDeleter
auditLogPath string
emailIndex *profile.ProfileEmailIndex // optional, maintained by mutations
}
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) *CustomerServer {
var del CredentialDeleter
if len(deleter) > 0 {
del = deleter[0]
}
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
}
// SetEmailIndex attaches a ProfileEmailIndex that is updated by every
// customer mutation (create, update, delete).
func (s *CustomerServer) SetEmailIndex(ix *profile.ProfileEmailIndex) {
s.emailIndex = ix
}
// indexProfileAfterMutation reads the grain and updates the email index.
func (s *CustomerServer) indexProfileAfterMutation(ctx context.Context, id uint64) {
if s.emailIndex == nil {
return
}
g, err := s.applier.Get(ctx, id)
if err != nil {
return
}
prefs := g.EmailPreferences
// Copy so the index holds its own snapshot (the grain is pooled).
if prefs != nil {
cp := &profile.EmailPreferences{
OrderEmails: prefs.OrderEmails,
MarketingEmails: prefs.MarketingEmails,
}
prefs = cp
}
s.emailIndex.Set(id, g.Email, prefs)
}
// ---------------------------------------------------------------------------
// UCP Customer endpoints
// ---------------------------------------------------------------------------
// handleCreateCustomer handles POST /customers — generates a new profile ID
// server-side (base62-encoded random 64-bit), applies the profile mutation,
// and returns 201 with the generated ID. Unlike PUT /customers/{id}, the
// caller does not choose the identifier.
func (s *CustomerServer) handleCreateCustomer(w http.ResponseWriter, r *http.Request) {
var req CustomerUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
rawId, err := profile.NewProfileId()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate id")
return
}
id := uint64(rawId)
msg := &messages.SetProfile{
Name: req.Name,
Email: req.Email,
Phone: req.Phone,
Language: req.Language,
Currency: req.Currency,
AvatarUrl: req.AvatarUrl,
}
if req.EmailPreferences != nil {
msg.OrderEmails = req.EmailPreferences.OrderEmails
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to create customer: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
s.indexProfileAfterMutation(r.Context(), id)
writeJSON(w, http.StatusCreated, customerGrainToResponse(rawId.String(), g))
}
// handleGetCustomer handles GET /customers/{id} with ETag-based conditional
// GET support. When the client sends If-None-Match matching the current ETag,
// a 304 Not Modified is returned with no body — the caller reuses their cached
// response. The ETag is derived from the grain's lastChange timestamp and
// customer data hash, so it changes on any mutation.
func (s *CustomerServer) handleGetCustomer(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
etag := computeCustomerETag(r.PathValue("id"), g)
if match := r.Header.Get("If-None-Match"); match != "" {
if match == etag || match == "*" {
w.Header().Set("ETag", etag)
w.Header().Set("Vary", "Accept-Encoding")
w.WriteHeader(http.StatusNotModified)
return
}
}
w.Header().Set("ETag", etag)
w.Header().Set("Vary", "Accept-Encoding")
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// handleUpdateCustomer handles PUT /customers/{id}.
func (s *CustomerServer) handleUpdateCustomer(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
var req CustomerUpdateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
msg := &messages.SetProfile{
Name: req.Name,
Email: req.Email,
Phone: req.Phone,
Language: req.Language,
Currency: req.Currency,
AvatarUrl: req.AvatarUrl,
}
if req.EmailPreferences != nil {
msg.OrderEmails = req.EmailPreferences.OrderEmails
msg.MarketingEmails = req.EmailPreferences.MarketingEmails
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update customer: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
s.indexProfileAfterMutation(r.Context(), id)
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// handleGetCustomerByEmail handles GET /customers/by-email/{email} — looks up
// the customer profile from the email index and returns the customer response.
// Returns 404 when the email is not found.
func (s *CustomerServer) handleGetCustomerByEmail(w http.ResponseWriter, r *http.Request) {
email := r.PathValue("email")
if email == "" || s.emailIndex == nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
entry, ok := s.emailIndex.Lookup(email)
if !ok {
writeError(w, http.StatusNotFound, "customer not found")
return
}
g, err := s.applier.Get(r.Context(), entry.ID)
if err != nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
writeJSON(w, http.StatusOK, customerGrainToResponse(profile.ProfileId(entry.ID).String(), g))
}
// handleAddAddress handles POST /customers/{id}/addresses.
func (s *CustomerServer) handleAddAddress(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
var req AddAddressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
if req.AddressLine1 == "" || req.City == "" || req.Zip == "" || req.Country == "" {
writeError(w, http.StatusBadRequest, "addressLine1, city, zip, and country are required")
return
}
addr := &messages.Address{
Label: req.Label,
FullName: req.FullName,
AddressLine1: req.AddressLine1,
City: req.City,
State: req.State,
Zip: req.Zip,
Country: req.Country,
IsDefaultShipping: req.IsDefaultShipping,
IsDefaultBilling: req.IsDefaultBilling,
}
if req.AddressLine2 != "" {
addr.AddressLine2 = &req.AddressLine2
}
if req.Phone != "" {
addr.Phone = &req.Phone
}
msg := &messages.AddAddress{Address: addr}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to add address: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
writeJSON(w, http.StatusCreated, customerGrainToResponse(r.PathValue("id"), g))
}
// handleUpdateAddress handles PUT /customers/{id}/addresses/{addressId}.
func (s *CustomerServer) handleUpdateAddress(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
addressID, ok := parseAddressID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid address id")
return
}
var req UpdateAddressRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return
}
msg := &messages.UpdateAddress{
Id: addressID,
Label: req.Label,
FullName: req.FullName,
AddressLine1: req.AddressLine1,
AddressLine2: req.AddressLine2,
City: req.City,
State: req.State,
Zip: req.Zip,
Country: req.Country,
Phone: req.Phone,
IsDefaultShipping: req.IsDefaultShipping,
IsDefaultBilling: req.IsDefaultBilling,
}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update address: "+err.Error())
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// handleRemoveAddress handles DELETE /customers/{id}/addresses/{addressId}.
func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
addressID, ok := parseAddressID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid address id")
return
}
msg := &messages.RemoveAddress{Id: addressID}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
// Check if the error is a "not found" error.
errStr := err.Error()
if containsNotFound(errStr) {
writeError(w, http.StatusNotFound, fmt.Sprintf("address %d not found", addressID))
return
}
writeError(w, http.StatusInternalServerError, "failed to remove address: "+errStr)
return
}
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to read customer: "+err.Error())
return
}
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// handleDeleteCustomer handles DELETE /customers/{id}.
func (s *CustomerServer) handleDeleteCustomer(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
// 1. Fetch current profile grain to get the email address.
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
email := g.Email
// 2. Apply the AnonymizeProfile mutation to clear all PII.
msg := &messages.AnonymizeProfile{}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to anonymize customer: "+err.Error())
return
}
// 3. Delete the credentials from the auth store if we have a deleter.
if email != "" && s.deleter != nil {
if _, err := s.deleter.Delete(r.Context(), email); err != nil {
fmt.Printf("ucp: failed to delete credentials for email %s: %v\n", email, err)
}
}
// 4. Log audit trail to file (GDPR requirement, append-only, readable, no PII)
if s.auditLogPath != "" {
logLine := fmt.Sprintf("[%s] ACTION=GDPR_ERASURE PROFILE_ID=%s STATUS=SUCCESS\n",
time.Now().UTC().Format(time.RFC3339), r.PathValue("id"))
// Import "os" package is handled or we can open it directly.
// Since we need to write to the file, let's open it in append-only mode.
// To ensure directory exists, we write to the file.
if f, err := os.OpenFile(s.auditLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
_, _ = f.WriteString(logLine)
_ = f.Close()
} else {
fmt.Printf("ucp: failed to open audit log file %s: %v\n", s.auditLogPath, err)
}
}
// Log audit trail to stdout (GDPR requirement)
fmt.Printf("AUDIT: GDPR right to erasure executed for customer profile id %d\n", id)
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Identity linking helpers
// ---------------------------------------------------------------------------
// linkCartToProfile links a cart to the given profile by applying a LinkCart mutation.
func linkCartToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, cartID uint64) error {
if applier == nil {
return nil
}
_, err := applier.Apply(ctx, profileID, &messages.LinkCart{
CartId: cartID,
Label: "current cart",
})
return err
}
// linkCheckoutToProfile links a checkout to the given profile by applying a LinkCheckout mutation.
func linkCheckoutToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, checkoutID uint64, cartID uint64) error {
if applier == nil {
return nil
}
_, err := applier.Apply(ctx, profileID, &messages.LinkCheckout{
CheckoutId: checkoutID,
CartId: cartID,
})
return err
}
// linkOrderToProfile links an order to the given profile by applying a LinkOrder mutation.
func linkOrderToProfile(applier ProfileApplier, ctx context.Context, profileID uint64, orderReference string, cartID uint64, status string) error {
if applier == nil {
return nil
}
_, err := applier.Apply(ctx, profileID, &messages.LinkOrder{
OrderReference: orderReference,
CartId: cartID,
Status: status,
})
return err
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// parseCustomerID parses the base62 customer id from the path.
func parseCustomerID(r *http.Request) (uint64, bool) {
raw := r.PathValue("id")
if raw == "" {
return 0, false
}
cid, ok := profile.ParseProfileId(raw)
if !ok {
return 0, false
}
return uint64(cid), true
}
// parseAddressID parses the numeric address id from the path.
func parseAddressID(r *http.Request) (uint32, bool) {
raw := r.PathValue("addressId")
if raw == "" {
return 0, false
}
// Address IDs are simple numeric strings.
var id uint32
for _, c := range raw {
if c < '0' || c > '9' {
return 0, false
}
id = id*10 + uint32(c-'0')
}
return id, true
}
// containsNotFound reports whether errStr indicates a "not found" condition.
func containsNotFound(errStr string) bool {
// Check for common not-found patterns in the error message.
for _, pattern := range []string{"not found", "not_found", "notfound"} {
if len(errStr) >= len(pattern) {
for i := 0; i <= len(errStr)-len(pattern); i++ {
match := true
for j := 0; j < len(pattern); j++ {
c1 := errStr[i+j]
c2 := pattern[j]
if c1 >= 'A' && c1 <= 'Z' {
c1 += 32
}
if c1 != c2 {
match = false
break
}
}
if match {
return true
}
}
}
}
return false
}
// computeCustomerETag returns a strong ETag for the customer's current state,
// derived from the grain's lastChange timestamp and a hash of the ID + change
// time. Any mutation (profile update, address change, linking) advances
// lastChange, so the ETag changes when data changes.
func computeCustomerETag(id string, g *profile.ProfileGrain) string {
h := sha256.Sum256([]byte(id + "\x00" + g.GetLastChange().Format(time.RFC3339Nano)))
return `"` + hex.EncodeToString(h[:16]) + `"`
}
// customerGrainToResponse converts a ProfileGrain to a CustomerResponse.
func customerGrainToResponse(id string, g *profile.ProfileGrain) CustomerResponse {
resp := CustomerResponse{
Id: id,
Name: g.Name,
Email: g.Email,
Phone: g.Phone,
Language: g.Language,
Currency: g.Currency,
AvatarUrl: g.AvatarUrl,
Addresses: make([]AddressResponse, 0, len(g.Addresses)),
}
for _, a := range g.Addresses {
resp.Addresses = append(resp.Addresses, AddressResponse{
Id: a.Id,
Label: a.Label,
FullName: a.FullName,
AddressLine1: a.AddressLine1,
AddressLine2: a.AddressLine2,
City: a.City,
State: a.State,
Zip: a.Zip,
Country: a.Country,
Phone: a.Phone,
IsDefaultShipping: a.IsDefaultShipping,
IsDefaultBilling: a.IsDefaultBilling,
})
}
if g.EmailPreferences != nil {
prefs := &EmailPreferencesResponse{
OrderEmails: g.EmailPreferences.OrderEmails,
MarketingEmails: g.EmailPreferences.MarketingEmails,
}
resp.EmailPreferences = prefs
}
return resp
}