204 lines
11 KiB
Go
204 lines
11 KiB
Go
// Package event is the platform's shared event vocabulary: one envelope shape
|
||
// (Event) and a small set of Type constants for the event meanings that
|
||
// genuinely cross module boundaries. It imports only the stdlib — never an app
|
||
// module, never a transport library — so every module can depend on it.
|
||
//
|
||
// Discipline: a Type constant earns its place only when a real producer emits it
|
||
// AND it is meaningful to another module. We do not pre-declare aspirational
|
||
// types. See docs/platform-event-messaging-sketch.md.
|
||
package event
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"time"
|
||
)
|
||
|
||
// Type is the semantic event kind — the cross-module contract. A module may use
|
||
// its own private kinds internally; only meanings that cross a boundary belong
|
||
// here.
|
||
type Type string
|
||
|
||
const (
|
||
// Editorial (graph-cms). Emitted by the CMS outbound bridge on content-graph
|
||
// mutations. In-repo consumers are TBD (external generators today); this is
|
||
// the first envelope producer, so the constants exist where they are emitted.
|
||
ContentCreated Type = "content.created"
|
||
ContentUpdated Type = "content.updated"
|
||
ContentDeleted Type = "content.deleted"
|
||
|
||
// Catalog (finder). These meanings already cross modules today — finder
|
||
// writer → reader and → backoffice (MountFinderListener) — but still travel
|
||
// as finder's legacy ChangeTopic strings on the wire. The constants document
|
||
// the shared vocabulary; the wire migration onto the envelope is a later step.
|
||
CatalogItemChanged Type = "catalog.item_changed" // legacy: finder "item_added"
|
||
CatalogSnapshotReady Type = "catalog.snapshot_ready" // legacy: finder "snapshot_ready"
|
||
CatalogSettingsChanged Type = "catalog.settings_changed" // legacy: finder "settings_change"
|
||
CatalogFacetChanged Type = "catalog.facet_changed" // legacy: finder "facet_change"
|
||
CatalogAccessoryChanged Type = "catalog.accessory_changed" // legacy: finder "accessory_change"
|
||
// CatalogProjectionPublished carries the commerce-facing catalog Projection
|
||
// (platform/catalog) — producer maps raw items → Projection once, consumers
|
||
// (cart / checkout / order / UCP) read the typed shape. Distinct from
|
||
// CatalogItemChanged (raw bodies, consumed by inventory/backoffice): a
|
||
// product index emits BOTH; a non-product index emits neither projection.
|
||
// Payload is a []catalog.ProjectionUpdate batch (upserts + deletes per SKU).
|
||
CatalogProjectionPublished Type = "catalog.projection_published"
|
||
CatalogSortOverride Type = "catalog.sort_override" // finder-internal: per-session/group/popularity overrides
|
||
CatalogFieldSortOverride Type = "catalog.field_sort_override" // finder-internal: live field ordering overrides
|
||
|
||
// Inventory (go-redis-inventory core, emitted by the cart-inventory service).
|
||
// A coarse stock-level crossing (none/low/high) for one SKU+location —
|
||
// never a quantity. Exact stock is a synchronous query against the inventory
|
||
// service; only level crossings cross a module boundary. Payload is an
|
||
// inventory.LevelChanged (platform/inventory). First consumer: finder stock
|
||
// overlay (display hints). See docs/inventory-shape.md.
|
||
InventoryLevelChanged Type = "inventory.level_changed"
|
||
|
||
// Commerce (order). Emitted by the order service saga after an order is
|
||
// placed-and-paid (a durable outbox event). The authoritative "a sale
|
||
// happened" fact — multiple modules react independently (inventory commits
|
||
// stock, fulfillment allocates) without checkout coupling to any of them.
|
||
// Payload is an order.Created (platform/order). See docs/inventory-shape.md
|
||
// and docs/fulfillment-stance.md.
|
||
OrderCreated Type = "order.created"
|
||
|
||
// Analytics (tracking). Replaces the integer enum 0–6 that finder and
|
||
// slask-tracking use on the `tracking` topic today. Producer: slask-finder
|
||
// (pkg/tracking/rabbit-tracking.go). Consumer: slask-tracking
|
||
// (pkg/events/rabbit-tracking.go).
|
||
TrackSession Type = "track.session" // 0 — session start
|
||
TrackSearch Type = "track.search" // 1 — search query
|
||
TrackClick Type = "track.click" // 2 — item click / product view
|
||
TrackCart Type = "track.cart" // 3,4 — add to / remove from cart
|
||
TrackImpression Type = "track.impression" // 5 — impression
|
||
TrackAction Type = "track.action" // 6 — custom action (wishlist, etc.)
|
||
TrackPageView Type = "track.page_view"
|
||
TrackPageEngaged Type = "track.page_engaged"
|
||
TrackPageAction Type = "track.page_action"
|
||
TrackExperiment Type = "track.experiment"
|
||
// TrackPurchase — cut 23. Post-checkout funnel closure; emitted by
|
||
// event_handlers.go::TrackPurchase on a purchase request. Replaces
|
||
// 5 hand-written `pevent.Type("track.purchase")` literals with this
|
||
// typed constant.
|
||
TrackPurchase Type = "track.purchase"
|
||
|
||
// Wishlist attribution (W6d of docs/plan-wishlist-favorites.md).
|
||
// Producers: profile service (share-page read) and cart service
|
||
// (list→cart conversion). Consumer: slask-tracking
|
||
// (pkg/events/rabbit-tracking.go) — feeds the saved-together
|
||
// co-occurrence group. Payload is an event.WishlistEvent — product
|
||
// ids only, never owner identity (C5 pseudonymity). Wire routing
|
||
// keys are `track.wishlist_viewed` / `track.wishlist_to_cart` so
|
||
// they ride the existing `track.*` pattern on the catalog exchange.
|
||
WishlistViewed Type = "wishlist.viewed" // public share-page read
|
||
WishlistToCart Type = "wishlist.to_cart" // list → cart conversion
|
||
|
||
// Wishlist mutation outbox (W6 hardening of docs/plan-wishlist-favorites.md).
|
||
// Producers: profile service (owner-scoped wishlist mutations, emitted
|
||
// from the UCP handlers on successful applies) and cart service (cart→list
|
||
// conversion). No in-repo consumer yet — these are the durable domain
|
||
// facts that future consumers bind to (price-drop / back-in-stock
|
||
// notifications, merchandising analytics, GDPR audit). Wire routing keys
|
||
// mirror the Type (`wishlist.created`, `wishlist.item_added`, …,
|
||
// `cart.to_wishlist`) on the catalog exchange. Unlike the tracking
|
||
// attribution pair above, these ride their own routing keys — NOT the
|
||
// `track.*` pattern — so slask-tracking's existing `track.*` binding
|
||
// does not pick them up. Payload: event.WishlistMutationEvent (profile)
|
||
// / event.CartToWishlistEvent (cart). See docs/amqp-topics.md.
|
||
WishlistCreated Type = "wishlist.created" // explicit user-created list (not the implicit default materialisation)
|
||
WishlistItemAdded Type = "wishlist.item_added" // one per SKU added (single-add and batch paths)
|
||
WishlistItemRemoved Type = "wishlist.item_removed"
|
||
WishlistShared Type = "wishlist.shared" // share (re)generated — token itself never leaves the service
|
||
CartToWishlist Type = "cart.to_wishlist" // cart → list conversion (cart service)
|
||
)
|
||
|
||
// WishlistEvent is the payload for the wishlist attribution events
|
||
// (WishlistViewed / WishlistToCart). Deliberately lean: list id + catalog
|
||
// item ids only — no owner id, no list name, no share token, so the
|
||
// pseudonymous tracking contract (C5) holds on the wire.
|
||
type WishlistEvent struct {
|
||
ListId uint32 `json:"listId"`
|
||
ItemIds []uint32 `json:"itemIds"`
|
||
}
|
||
|
||
// WishlistMutationEvent is the payload for the wishlist mutation outbox
|
||
// events (WishlistCreated / WishlistItemAdded / WishlistItemRemoved /
|
||
// WishlistShared). Fields are optional per event:
|
||
//
|
||
// wishlist.created → ListId + Name
|
||
// wishlist.item_added → ListId + Sku + ItemId
|
||
// wishlist.item_removed → ListId + Sku (+ ItemId when known)
|
||
// wishlist.shared → ListId + Name
|
||
//
|
||
// No owner identity, no share token (C5): a consumer can correlate by list
|
||
// id but never attribute a list to a customer from the event alone.
|
||
type WishlistMutationEvent struct {
|
||
ListId uint32 `json:"listId"`
|
||
Name string `json:"name,omitempty"`
|
||
Sku string `json:"sku,omitempty"`
|
||
ItemId uint32 `json:"itemId,omitempty"`
|
||
}
|
||
|
||
// CartToWishlistEvent is the payload for cart.to_wishlist (cart service).
|
||
// Carries the pseudonymised owner profile id (the cart's UserId — a
|
||
// customer-scoped opaque uint64 id, never an email/name) plus the list the
|
||
// lines landed in and the SKUs copied. Unlike the tracking pair, this is a
|
||
// domain outbox event on its own routing key; a future consumer (e.g.
|
||
// price-drop) needs the owner to address the customer.
|
||
type CartToWishlistEvent struct {
|
||
OwnerId uint64 `json:"ownerId"`
|
||
ListId uint32 `json:"listId"`
|
||
ListName string `json:"listName,omitempty"`
|
||
Skus []string `json:"skus,omitempty"`
|
||
}
|
||
|
||
// KnownTypes lists every event that has a declared Track* constant above.
|
||
// New pevent constants MUST also be added here so IsKnownType stays in
|
||
// sync. Used by HTTP handlers' forward-validation (e.g. SessionsPanel
|
||
// ?event_types= param, cut 25) so caller's typo's surface as a 400
|
||
// rather than silently dropping unknown tokens.
|
||
// KnownTypes is deliberately Track*-only: IsKnownType validates the
|
||
// user-facing `track.*` event-type query surface (SessionsPanel
|
||
// ?event_types= etc.). The wishlist attribution types (WishlistViewed /
|
||
// WishlistToCart) are consumed exclusively over AMQP and never appear in
|
||
// that HTTP param surface, so they stay out of this list (the drift-guard
|
||
// test enforces the Track* discipline).
|
||
var KnownTypes = []Type{
|
||
TrackSession, TrackSearch, TrackClick, TrackCart, TrackPurchase,
|
||
TrackImpression, TrackAction, TrackPageView, TrackPageEngaged,
|
||
TrackPageAction, TrackExperiment,
|
||
}
|
||
|
||
// IsKnownType reports whether s matches one of the declared Track*
|
||
// constants. Case-sensitive + exact-match. Callers MUST pass the canonical
|
||
// lowercase form (e.g. "track.search", NOT "Track.Search" or
|
||
// "TRACK.SEARCH"). Track* constants are declared lowercase in this file,
|
||
// and this function matches the literal string value of each constant.
|
||
// Callers should validate with this BEFORE casting untrusted input to Type.
|
||
func IsKnownType(s string) bool {
|
||
for _, t := range KnownTypes {
|
||
if string(t) == s {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
|
||
// Event is the one envelope. Payload is per-Type, agreed between producer and
|
||
// consumer (json.RawMessage, not a god-struct). A nil Payload means "the subject
|
||
// changed; fetch the latest state."
|
||
type Event struct {
|
||
ID string `json:"id"` // unique; enables idempotent/at-least-once dedupe
|
||
Type Type `json:"type"` // the vocabulary
|
||
Source string `json:"source"` // module identifier ("graph-cms", "finder", ...)
|
||
Subject string `json:"subject"` // the resource that changed (node id, sku, ...)
|
||
Payload json.RawMessage `json:"payload,omitempty"` // per-Type body, or nil
|
||
Time time.Time `json:"time"`
|
||
Meta map[string]string `json:"meta,omitempty"` // trace id, causation, country, dataType, ...
|
||
}
|
||
|
||
// Handler consumes an event. Returning an error means "not handled" — durable
|
||
// transports may redeliver; fire-and-forget transports just log.
|
||
type Handler func(ctx context.Context, e Event) error
|