// 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" // TrackPromotionView / TrackPromotionClick — storefront promotion // attribution. Producer: the storefront promotion-badge islands, PLP // promo badges, bundle picker and cart bundle upsell (lib/track.ts // beacons → POST /api/track/promotion). Consumer: slask-tracking // (pkg/view PromoStats) — per-promotion view/click counters for the // promotions admin. Payload is the wire PromotionEvent (promotion id, // action, placement, optional item id). These ride the browser beacon // path only (no AMQP producer today), so they stay out of the // `track.*` RabbitMQ routing pattern by construction. TrackPromotionView Type = "track.promotion_view" TrackPromotionClick Type = "track.promotion_click" // TrackEvent — free-form named storefront event (custom analytics // signals the storefront wires ad hoc: open_cart, plp_sort_change, // plp_filter_change, …). Producer: lib/track.ts trackEvent() beacons / // the data-track attribute island (POST /api/track/event). Consumer: // slask-tracking (pkg/view CustomEvent → pkg/customstats) — per-name // counters for the tracking read surface. Payload is the wire // CustomEvent (name + flat string props). Browser-beacon path only, // like the promotion pair. TrackEvent Type = "track.event" // Newsletter campaign attribution (review 3.8). Producer: the order // service's MailerSend webhook receiver (cmd/order/newsletter-webhook.go) // when MailerSend reports an open / click for a newsletter send. Consumer: // slask-tracking (pkg/events/rabbit-tracking.go) — feeds per-send // open/click rates for the backoffice delivery log. Payload is an // event.CampaignEvent. Wire routing keys `track.campaign_open` / // `track.campaign_click` ride the existing `track.*` pattern on the // catalog exchange. TrackCampaignOpen Type = "track.campaign_open" TrackCampaignClick Type = "track.campaign_click" // 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) ) // CampaignEvent is the payload for the newsletter campaign attribution // events (TrackCampaignOpen / TrackCampaignClick). Deliberately pseudonymous // (C5): it carries the send id (the `newsletter:{sendID}` MailerSend tag // parsed from the webhook) and a sha256 hex hash of the recipient email, // never the address itself. At is unix seconds (MailerSend's created_at; // falls back to arrival time when absent). // // URL is the link that was clicked (TrackCampaignClick only): the webhook // receiver stamps the MailerSend click URL so slask-tracking can aggregate // top-clicked links per send. Empty for opens and for clicks whose payload // carried no URL. type CampaignEvent struct { SendID string `json:"sendId"` EmailHash string `json:"emailHash"` At int64 `json:"at,omitempty"` URL string `json:"url,omitempty"` } // 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, TrackPromotionView, TrackPromotionClick, TrackEvent, // Track* constants without a browser beacon: consumed over AMQP only, // but the drift-guard test requires every declared Track* constant to // appear here so the HTTP ?event_types= surface stays exhaustive. TrackCampaignOpen, TrackCampaignClick, } // 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