messaging

package
v1.1.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 23, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

Documentation

Overview

Package messaging provides rabbitMQ integration, outbox/inbox patterns, and background workers for reliable asynchronous communication between microservices.

Index

Examples

Constants

View Source
const (
	// NotificationCmdSendEmailQueue carries send-email commands to the notification-service. Messages on this queue contain an EmailSendData payload and trigger an outbound email via SES.
	NotificationCmdSendEmailQueue = "notification_cmd_send_email"

	// NotifyEmailStatusQueue carries email delivery status updates (bounces, complaints, delivery confirmations) back from SES via SNS webhook. The notification-service consumes these to update email delivery records.
	NotifyEmailStatusQueue = "notify_email_status"

	// NotificationEventEmailLogQueue carries email-logged events emitted by the notification-service after successfully sending an email. Downstream consumers use these to maintain email audit records.
	NotificationEventEmailLogQueue = "notification_event_email_log"

	// LoggingEventRequestLogQueue carries request-log events for centralized logging.
	LoggingEventRequestLogQueue = "logging_event_request_log"

	// PlatformEventAuditLogQueue carries audit events produced by application services and needs to be persisted by platform-service.
	PlatformEventAuditLogQueue = "platform_event_audit_log"

	// CoreCmdPurgeAccountDataQueue carries purge-account-data commands to the core-service. Messages on this queue trigger deletion of all account-scoped data across ~50 tables for a deleted sandbox account.
	CoreCmdPurgeAccountDataQueue = "core_cmd_purge_account_data"

	// CoreCmdSeedSandboxQueue carries seed-sandbox commands to the core-service. Messages on this queue trigger population of a sandbox account with tutorial seed data.
	CoreCmdSeedSandboxQueue = "core_cmd_seed_sandbox"

	// CoreCmdExecuteProductionStepQueue carries execute-production-step commands to the core-service. Messages on this queue trigger inventory updates and reservation management after batch mutations (initialize, move, merge, split).
	//
	// Superseded by CoreEventBatchScannedInventoryQueue. Kept so commands already enqueued when the
	// switch happens still drain; delete once it has been empty across a deploy.
	CoreCmdExecuteProductionStepQueue = "core_cmd_execute_production_step"

	// CoreEventInventoryReceivedAllocationQueue carries inventory-received events to the core-service consumer that offers the new stock to whatever demand went short waiting for it.
	CoreEventInventoryReceivedAllocationQueue = "core_event_inventory_received_allocation"

	// CoreEventItemCostBasisChangedQueue carries cost-basis-changed events to the core-service consumer that recomputes the cost of every item downstream of the change.
	CoreEventItemCostBasisChangedQueue = "core_event_item_cost_basis_changed_costing"

	// CoreEventBatchScannedInventoryQueue carries batch-scanned events to the core-service consumer that moves inventory: the produced receipt, the reservations seconds and waste release, and the material consumption.
	//
	// The queue is named for what its consumer does, not for the event, because a topic exchange gives every bound queue its own copy while consumers sharing a queue compete for messages. A second reaction to the same scan — crediting schedule attainment, say — binds CoreEventBatchScanned on a queue of its own and both run.
	CoreEventBatchScannedInventoryQueue = "core_event_batch_scanned_inventory"

	// carries undo-batch-scan commands to the core-service, reversing the receipts, issues, and reservations a scan recorded against a deleted batch.
	CoreCmdUndoBatchScanQueue = "core_cmd_undo_batch_scan"

	// CoreCmdSyncStripeCustomerQueue carries sync-stripe-customer commands to the core-service, which creates or updates the customer's counterpart in the account's connected Stripe integration. Messages contain a SyncStripeCustomerEvent payload.
	CoreCmdSyncStripeCustomerQueue = "core_cmd_sync_stripe_customer"

	// carries a request to solve and persist one production schedule version. The cadence tick only enqueues onto this queue: a solve takes minutes on a real tenant, and doing it inside the scheduler lease would block every other account behind whichever one is currently solving.
	CoreCmdGenerateProductionScheduleQueue = "core_cmd_generate_production_schedule"

	// carries sales-order-created events back to the core-service for out-of-band processing (e.g. CRM sync). Messages on this queue contain a SalesOrderCreatedData payload.
	CoreEventSalesOrderCreatedQueue = "core_event_sales_order_created"

	// CoreEventSalesOrderShippingUpdatedQueue carries sales-order shipping-changed events back to the core-service, which re-syncs the order's shipment records' carrier / service level / ship-to. Messages contain a SalesOrderShippingUpdatedData payload.
	CoreEventSalesOrderShippingUpdatedQueue = "core_event_sales_order_shipping_updated"

	// CoreEventCustomerRegisteredQueue carries customer-registered events to the notification-service, which notifies the seller's customer-service support-route group. Messages contain a CustomerRegisteredData payload.
	CoreEventCustomerRegisteredQueue = "core_event_customer_registered"

	// CoreCmdHubspotSyncQueue carries HubSpot backfill commands (preview and execute) to the core-service, which runs the long-running matching/sync passes out-of-band. Bound to both the preview and execute routing keys; the consumer dispatches on routing key. Messages contain a HubspotSyncCommandData payload.
	CoreCmdHubspotSyncQueue = "core_cmd_hubspot_sync"

	// BillingEventStripeWebhookQueue carries verified Stripe webhook events for asynchronous processing by the billing-service. The raw event payload and metadata are enqueued immediately on receipt so the webhook endpoint can return as fast as possible.
	BillingEventStripeWebhookQueue = "billing_event_stripe_webhook"

	// AgentCmdExecuteRunQueue carries execute-run commands to the agent-service. Messages trigger an agent run for a specific account and agent configuration.
	AgentCmdExecuteRunQueue = "agent_cmd_execute_run"

	// AgentCmdExecuteActionQueue carries execute-action commands to the agent-service. Messages trigger execution of a proposed agent action after optional human review.
	AgentCmdExecuteActionQueue = "agent_cmd_execute_action"

	// AgentCmdContinueRunQueue carries continue-run commands to the agent-service. Messages trigger continuation of an agent run that is awaiting user input.
	AgentCmdContinueRunQueue = "agent_cmd_continue_run"

	// AgentCmdChatRunQueue carries chat-run commands (from notification-service) to the agent-service: create a chat-linked run and execute it.
	AgentCmdChatRunQueue = "agent_cmd_chat_run"

	// AgentEventRunCompletedQueue carries run-completed events emitted by the agent-service after an agent run finishes. It is the durable, shared work queue for billing-service token/usage aggregation (exactly-once across billing replicas). The api-gateway also consumes run-completed events for WebSocket fan-out, but via its own per-instance queue (ConsumeFanout with this base name) so every gateway replica gets a copy — it must not join this shared queue, or billing and the gateway would steal each other's events.
	AgentEventRunCompletedQueue = "agent_event_run_completed"

	// AgentEventRunStepQueue is the base name for the queue that carries individual run step events for real-time WebSocket streaming. Each API gateway instance appends a unique suffix to create its own exclusive auto-delete queue so that every instance receives every event via RabbitMQ fanout.
	AgentEventRunStepQueue = "agent_event_run_step"

	// BillingCmdSyncSeatsQueue carries sync-seats commands to the billing-service. Messages on this queue trigger a seat count reconciliation with Stripe.
	BillingCmdSyncSeatsQueue = "billing_cmd_sync_seats"

	// BillingCmdReportSeatChangeQueue carries report-seat-change commands to the billing-service. Messages on this queue trigger a usage meter report to Stripe.
	BillingCmdReportSeatChangeQueue = "billing_cmd_report_seat_change"

	// Carries report-invoice-created commands to the billing-service; each triggers a usage meter report to Stripe.
	BillingCmdReportInvoiceCreatedQueue = "billing_cmd_report_invoice_created"

	// NotificationCmdFanoutQueue carries alert/message fan-out intents to notification-service. It is inbox-deduped and bound to NotificationCmdFanout (and NotificationCmdSendMessage).
	NotificationCmdFanoutQueue = "notification_cmd_fanout"

	// NotificationCmdAgentReplyQueue carries an agent's chat reply (from agent-service) to notification-service. Inbox-deduped and bound to NotificationCmdAgentReply.
	NotificationCmdAgentReplyQueue = "notification_cmd_agent_reply"

	// NotificationCmdAgentReplyPatchQueue carries best-effort partial-body updates for an in-flight streaming agent reply. Not inbox-deduped (patches are idempotent last-write-wins) and bound to NotificationCmdAgentReplyPatch.
	NotificationCmdAgentReplyPatchQueue = "notification_cmd_agent_reply_patch"

	// NotificationEventDeliveredQueue is the base name for the realtime push queue consumed by api-gateway. Like AgentEventRunStepQueue, each gateway instance appends a unique suffix to create its own exclusive auto-delete queue (bound to NotificationEventDelivered and NotificationEventConversationUpdated) so every instance receives every event via RabbitMQ fanout.
	NotificationEventDeliveredQueue = "notification_event_delivered"

	// DeadLetterQueue is the catch-all queue for messages that could not be processed after exhausting retries. It is bound to the dead-letter exchange ("dlx") so rejected or expired messages from any queue land here for manual inspection.
	DeadLetterQueue = "dead_letter_queue"
)

Queue name constants define the AMQP queue names used across all services. Each queue is bound to the application exchange ("app") with a routing key matching its name. The naming convention is "{service}_{cmd|event}_{action}" to make ownership and intent clear at a glance.

Command queues ("cmd") carry instructions that should trigger side effects (e.g. sending an email). Event queues ("event") carry facts about things that already happened (e.g. an email was sent).

View Source
const (
	// ApplicationExchange is the primary topic exchange that all services publish to and consume from. Messages are routed by their routing key (message type) to the appropriate queue bindings.
	ApplicationExchange = "app"
)

Variables

lists every registered bulk operation; the rabbitmq bindings declare a queue per entry and the core-service wiring pairs each with its executor

lists every registered export; the rabbitmq bindings declare a queue per entry and the core-service wiring pairs each with the resource it renders

Functions

func OutboxDBRetryConfig

func OutboxDBRetryConfig(platformMode constants.PlatformMode) *retry.Config

OutboxDBRetryConfig returns the short retry policy used for small outbox database operations that can safely be re-attempted after a lock conflict.

func WithOutboxDBLockRetry

func WithOutboxDBLockRetry(ctx context.Context, cfg *retry.Config, operation string, fn func() error) error

WithOutboxDBLockRetry retries operation only for transient database lock conflicts. Callers should use it for small outbox operations whose retry is idempotent, not for broader business transactions.

Types

type AgentChatRunData

type AgentChatRunData struct {
	AccountID         string `json:"account_id"`
	AgentDefinitionID string `json:"agent_definition_id"`
	ConversationID    string `json:"conversation_id"`
	TriggerMessageID  string `json:"trigger_message_id"`
	Message           string `json:"message"`
	// History is the recent thread context preceding the trigger, oldest-first, so the agent can follow the conversation instead of seeing only the triggering message. Role is from this agent's perspective: its own past replies are "assistant", everyone else is "user". Only set when starting a new run — a continued run already carries its own history.
	History []ChatHistoryMessage `json:"history,omitempty"`
	// ContinueRunID, when set, is the run to continue (the user replied directly to that run's message) instead of starting a new run. Empty means start a fresh run.
	ContinueRunID string `json:"continue_run_id,omitempty"`
}

AgentChatRunData is the payload for AgentCmdChatRunQueue: start an agent run from a chat message. agent-service creates a chat-linked run (conversation_id + trigger_message_id, trigger_type=chat, input=Message) for the agent definition and executes it; on completion the run posts its reply back into the conversation. AgentDefinitionID is the participant's agent identifier.

type AgentContinueRunData

type AgentContinueRunData struct {
	AgentRunID        string   `json:"agent_run_id"`
	AccountID         string   `json:"account_id"`
	Message           string   `json:"message"`
	ApprovedToolSlugs []string `json:"approved_tool_slugs,omitempty"`
	// ApproveAllPending, when true, approves every still-pending review-gated tool on the run (the "Approve all" control). It is the only way an empty ApprovedToolSlugs grants approval — set solely on an explicit approval with no specific slugs. A typed-message continuation or a retry leaves it false, so a blocked tool is never silently let through and re-prompts the next time it is called.
	ApproveAllPending bool `json:"approve_all_pending,omitempty"`
	// RejectedToolSlugs are the review-gated tools the human denied on this resume. The run continues; the runner answers each with a synthetic "denied by user" tool result so the agent proceeds without them.
	RejectedToolSlugs []string `json:"rejected_tool_slugs,omitempty"`
	// ApprovedToolCallIDs / RejectedToolCallIDs are per-call decisions: the tool_use_ids of individual blocked
	// calls the human approved/denied. Unlike the slug lists (which apply to every pending call of a slug),
	// these target one specific call, so two same-slug calls with different inputs are decided independently.
	ApprovedToolCallIDs []string `json:"approved_tool_call_ids,omitempty"`
	RejectedToolCallIDs []string `json:"rejected_tool_call_ids,omitempty"`
	ActorID             string   `json:"actor_id,omitempty"`
	ActorType           string   `json:"actor_type,omitempty"`
	ActorName           string   `json:"actor_name,omitempty"`
	// ReplyToMessageID threads this turn's reply under the message that triggered the continuation (the user's reply to the agent), so a chat thread keeps growing as one. Empty for non-chat continuations (e.g. the agent-run console).
	ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
}

AgentContinueRunData is the payload for AgentCmdContinueRunQueue messages. It carries the run ID, account ID, and user message for continuing a run.

type AgentExecuteActionData

type AgentExecuteActionData struct {
	AgentActionID   string          `json:"agent_action_id"`
	ToolSlug        string          `json:"tool_slug"`
	ProposedPayload json.RawMessage `json:"proposed_payload"`
	AccountID       string          `json:"account_id"`
}

AgentExecuteActionData is the payload for AgentCmdExecuteActionQueue messages. It carries a proposed action for execution after optional human review.

type AgentExecuteRunData

type AgentExecuteRunData struct {
	AgentRunID    string `json:"agent_run_id"`
	AgentConfigID string `json:"agent_config_id"`
	AccountID     string `json:"account_id"`
	TriggerType   string `json:"trigger_type"`
}

AgentExecuteRunData is the payload for AgentCmdExecuteRunQueue messages. It identifies which agent config to run for which account.

type AgentReplyData

type AgentReplyData struct {
	AccountID      string `json:"account_id"`
	ConversationID string `json:"conversation_id"`
	AgentConfigID  string `json:"agent_config_id"`
	// AgentName is the agent definition's display name, carried so chat bell notifications can title themselves after the agent (name lives in agent-service, not notification-service).
	AgentName       string `json:"agent_name,omitempty"`
	AgentRunID      string `json:"agent_run_id"`
	Body            string `json:"body"`
	ClientMessageID string `json:"client_message_id"`
	// MessageID is the agent-generated message row id, shared by the start/patch/final messages of one streaming reply so they address the same record. Empty falls back to a service-generated id.
	MessageID string `json:"message_id,omitempty"`
	// Phase is "start" | "final" | "" (legacy single-shot create-and-complete).
	Phase string `json:"phase,omitempty"`
	// Failed marks a "final" reply that resolves an errored run (the body is the user-facing error).
	Failed bool `json:"failed,omitempty"`
	// ErrorCode is the machine-readable api-error code for a failed reply (e.g. "agent_spending_cap_reached"), carried onto the message so the client can react (e.g. prompt to raise the spending limit). Empty for non-cap or non-failed replies.
	ErrorCode string `json:"error_code,omitempty"`
	// ReplyToMessageID threads the reply under the message that triggered the run (a mention or keyword), so it renders as a reply. Empty for continuation turns (already in a reply thread).
	ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
	// ApprovalEvent marks this as a tool-approval notice rather than an agent reply: the consumer posts it as a senderless system_event (Body = "{approver} approved {tools}") that renders as a timeline divider, not an agent bubble. AgentConfigID/AgentRunID are not required in this mode.
	ApprovalEvent bool `json:"approval_event,omitempty"`
}

AlertFanoutData is the payload for NotificationCmdFanoutQueue messages. A producer (core/agent/billing/notification-service) emits one alert/message intent; the notification-service consumer turns it into a message plus per-recipient notification rows and a realtime push. Content may be templated (TemplateKey/TemplateParams) for i18n, with Title/Body as default-locale fallbacks. AgentReplyData is the payload for NotificationCmdAgentReplyQueue: an agent's reply to post into a conversation. notification-service resolves the agent participant from (ConversationID, AgentConfigID), creates a kind=agent message authored by that participant and linked to AgentRunID, and fans it out. ClientMessageID makes it idempotent across redelivery.

A streaming reply spans two of these: Phase "start" creates the row empty and in streaming_state, then Phase "final" sets the finished body and flips it to complete (and fires the bell). MessageID is the agent-owned row id shared across start/patch/final so they target the same record. Phase "" is the legacy single-shot path (create-and-complete in one message). Failed marks a "final" that resolves a started bubble to an error message.

type AgentReplyPatchData

type AgentReplyPatchData struct {
	AccountID      string `json:"account_id"`
	ConversationID string `json:"conversation_id"`
	MessageID      string `json:"message_id"`
	Body           string `json:"body"`
}

AgentReplyPatchData is the payload for NotificationCmdAgentReplyPatchQueue: a best-effort partial-body update for an in-flight streaming agent reply. Body is the full accumulated answer so far (not a delta), so a dropped or reordered patch never corrupts the record — the next patch or the "final" reply reconciles it. notification-service updates the row (without touching edited_at) and pushes a server-only message.updated to the conversation's live subscribers.

type AgentRunCompletedData

type AgentRunCompletedData struct {
	AgentRunID       string `json:"agent_run_id"`
	AccountID        string `json:"account_id"`
	BillingAccountID string `json:"billing_account_id"`
	InputTokens      int    `json:"input_tokens"`
	OutputTokens     int    `json:"output_tokens"`
	TotalTokens      int    `json:"total_tokens"`
	LLMProvider      string `json:"llm_provider"`
	LLMModel         string `json:"llm_model"`
}

AgentRunCompletedData is the payload for AgentEventRunCompletedQueue messages. It carries token usage and model metadata for billing aggregation.

type AgentRunStepData

type AgentRunStepData struct {
	AgentRunID string          `json:"agent_run_id"`
	AccountID  string          `json:"account_id"`
	EventID    string          `json:"event_id"`
	StepType   string          `json:"step_type"`
	Title      string          `json:"title"`
	Content    *string         `json:"content,omitempty"`
	Sequence   int             `json:"sequence"`
	DurationMs *int32          `json:"duration_ms,omitempty"`
	ActionID   *string         `json:"action_id,omitempty"`
	Metadata   json.RawMessage `json:"metadata,omitempty"`
	CreatedAt  string          `json:"created_at"`
	ActorID    string          `json:"actor_id,omitempty"`
	ActorType  string          `json:"actor_type,omitempty"`
	ActorName  string          `json:"actor_name,omitempty"`
	// Terminal marks this step as the run's final event (e.g. the "Run failed" error step). The WS gateway, on seeing it, also emits a terminal run_complete frame on the run topic so the frontend leaves its loading state. Successful/awaiting runs already get that frame from the run-completed event; a failed run only emits this step, so without this flag the live run view stays stuck loading until a hard refresh re-fetches the persisted "failed" status.
	Terminal bool `json:"terminal,omitempty"`
}

AgentRunStepData is the payload for AgentEventRunStepQueue messages. It carries a single run step event for real-time WebSocket streaming.

type AlertFanoutData

type AlertFanoutData struct {
	AccountID        string           `json:"account_id"`
	Category         string           `json:"category"`
	ConversationRef  *ConversationRef `json:"conversation_ref,omitempty"`
	Kind             string           `json:"kind"` // system_event | alert | chat | agent
	Title            string           `json:"title"`
	Body             string           `json:"body,omitempty"`
	Preview          string           `json:"preview,omitempty"`
	TemplateKey      string           `json:"template_key,omitempty"`
	TemplateParams   json.RawMessage  `json:"template_params,omitempty"`
	LinkResourceType string           `json:"link_resource_type,omitempty"`
	LinkResourceID   string           `json:"link_resource_id,omitempty"`
	Priority         string           `json:"priority,omitempty"`
	// Polymorphic sender attribution (user | group | system | agent | apikey).
	SenderType string `json:"sender_type,omitempty"`
	SenderID   string `json:"sender_id,omitempty"`
	SenderName string `json:"sender_name,omitempty"`
	// RecipientAccountUserIDs is an explicit recipient list (account_user ids); empty + Broadcast=true means all active users in the account.
	RecipientAccountUserIDs []string `json:"recipient_account_user_ids,omitempty"`
	// RecipientUserIDs are user ids (us_) the fan-out resolves to account_user ids within AccountID — for producers (e.g. agent-service) that hold the user id, not the account_user id.
	RecipientUserIDs []string        `json:"recipient_user_ids,omitempty"`
	Broadcast        bool            `json:"broadcast,omitempty"`
	DedupeKey        string          `json:"dedupe_key,omitempty"`
	Metadata         json.RawMessage `json:"metadata,omitempty"`
}

type BulkOperation

type BulkOperation string

names one async bulk operation, and stems its routing key, queue and inbox handler so those cannot drift apart. Persisted in the inbox: renaming one orphans in-flight records.

const (
	BulkCreateProductionRuns   BulkOperation = "bulk_create_production_runs"
	BulkUpsertProductionSteps  BulkOperation = "bulk_upsert_production_steps"
	BulkUpsertUnits            BulkOperation = "bulk_upsert_units"
	BulkUpsertUnitGroups       BulkOperation = "bulk_upsert_unit_groups"
	BulkUpsertLocations        BulkOperation = "bulk_upsert_locations"
	BulkUpsertDepartments      BulkOperation = "bulk_upsert_departments"
	BulkUpsertMachines         BulkOperation = "bulk_upsert_machines"
	BulkUpsertProductLines     BulkOperation = "bulk_upsert_product_lines"
	BulkUpsertScanningStations BulkOperation = "bulk_upsert_scanning_stations"
	BulkUpsertItemCategories   BulkOperation = "bulk_upsert_item_categories"
	BulkUpsertParts            BulkOperation = "bulk_upsert_parts"
	BulkUpsertProducts         BulkOperation = "bulk_upsert_products"
	BulkUpsertMaterials        BulkOperation = "bulk_upsert_materials"
	BulkUpsertProperties       BulkOperation = "bulk_upsert_properties"
	// BulkResolveHubspotCompanyReviews applies many company-match decisions at once, the path a reviewed spreadsheet comes back through.
	BulkResolveHubspotCompanyReviews BulkOperation = "bulk_resolve_hubspot_company_reviews"
	// Packs one pick into a shipment — not a row batch, but it carries the same {job_id} payload and rides the same consumer.
	PackPick BulkOperation = "pack_pick"
)

The canonical bulk operations

func (BulkOperation) Handler

func (o BulkOperation) Handler() string

builds the inbox handler key, which is part of the persisted dedup identity

func (BulkOperation) Queue

func (o BulkOperation) Queue() string

builds the command queue name

func (BulkOperation) RoutingKey

func (o BulkOperation) RoutingKey() contracts.AmqpRoutingKey

builds the AMQP command routing key

func (BulkOperation) String

func (o BulkOperation) String() string

type ChatHistoryMessage

type ChatHistoryMessage struct {
	// Role is "assistant" for the dispatched agent's own earlier replies, "user" for everyone else.
	Role string `json:"role"`
	// Name is the sender's display name when known (people); empty for agents.
	Name string `json:"name,omitempty"`
	// AgentConfigID is set when a *different* agent authored this turn. Its display name lives in agent-service (not resolvable in notif-service), so it's carried here and resolved into Name when the run is created. Empty for people and the dispatched agent's own turns.
	AgentConfigID string `json:"agent_config_id,omitempty"`
	Body          string `json:"body"`
}

ChatHistoryMessage is one prior turn of conversation context for a chat-triggered agent run.

type CleanupConfig

type CleanupConfig struct {
	// Interval (optional; default: 24h) is how often the cleanup loop fires after the first run. Each tick triggers a full run that processes up to MaxBatchesPerRun batches for each table.
	Interval time.Duration

	// BatchSize (optional; default: 1000) is the maximum number of expired rows deleted in a single SQL DELETE statement. Keeping this bounded prevents table-lock escalation on MySQL and limits the replication lag each batch introduces.
	BatchSize int

	// MaxBatchesPerRun (optional; default: 100) caps the total number of sequential DELETE batches executed for each table in a single cleanup run. The effective ceiling per run is BatchSize * MaxBatchesPerRun rows per table.
	MaxBatchesPerRun int

	// LeaseName (optional; default: "idempotency-cleanup") identifies the distributed lease this worker acquires before each run so only one pod does the DELETE work.
	LeaseName string

	// LeaseTTL (optional; default: 5m) is how long the lease is held before expiring. A full cleanup run typically completes within a few seconds; the TTL is only a safety net for a crashed holder.
	LeaseTTL time.Duration

	// ScheduleLocation (optional; default: America/New_York) is the timezone used to determine when "midnight" falls for the first scheduled run. The worker waits until the next midnight in this location before running for the first time, then repeats every Interval thereafter.
	ScheduleLocation *time.Location
}

CleanupConfig holds the configuration for the cleanup worker, which deletes expired rows from the idempotency key tables and old deleted_record entries.

func (*CleanupConfig) WithDefaults

func (c *CleanupConfig) WithDefaults() *CleanupConfig

WithDefaults returns a new CleanupConfig with zero-value fields replaced by production defaults.

type CleanupRepo

type CleanupRepo interface {
	// DeleteExpiredIdempotencyKeys deletes up to `limit` rows from the idempotency_key table whose expires_at timestamp has passed. These are the api-gateway–level keys used to deduplicate inbound HTTP requests. Returns the number of rows actually deleted; the caller uses this to decide whether another batch is needed (deleted < limit means the table is caught up).
	DeleteExpiredIdempotencyKeys(ctx context.Context, limit int) (int64, error)

	// DeleteExpiredServiceIdempotencyKeys deletes up to `limit` rows from the service_idempotency_key table whose expires_at timestamp has passed. These are the service-level keys used by individual gRPC handlers to prevent duplicate side effects. Returns the number of rows actually deleted.
	DeleteExpiredServiceIdempotencyKeys(ctx context.Context, limit int) (int64, error)

	// DeleteExpiredDeletedRecords deletes up to `limit` rows from deleted_record older than the retention window. Returns the number of rows actually deleted.
	DeleteExpiredDeletedRecords(ctx context.Context, limit int) (int64, error)

	// DeleteExpiredRequestLogs deletes up to `limit` rows from request_log older than 7 years. Returns the number of rows actually deleted.
	DeleteExpiredRequestLogs(ctx context.Context, limit int) (int64, error)

	// DeleteExpiredAuditEvents deletes up to `limit` rows from audit_event older than 7 years. Returns the number of rows actually deleted.
	DeleteExpiredAuditEvents(ctx context.Context, limit int) (int64, error)
}

CleanupRepo defines the persistence interface for deleting expired idempotency keys and old deleted records. The platform-service implements this interface. Methods accept a row limit so the caller can bound DELETE scope and iterate in batches.

type CleanupWorker

type CleanupWorker struct {
	// contains filtered or unexported fields
}

CleanupWorker runs a single background goroutine that periodically purges expired idempotency keys and old deleted records. It waits until the next midnight in the configured ScheduleLocation before its first run, then repeats every CleanupConfig.Interval.

The worker uses appctx.WithNoTrace to suppress trace spans for its background operations, and respects context cancellation — calling Stop blocks until the goroutine exits cleanly.

func NewCleanupWorker

func NewCleanupWorker(config *CleanupConfig, repo CleanupRepo, l *lease.Lease) (*CleanupWorker, error)

NewCleanupWorker creates a new cleanup worker. A non-nil lease is required — each run claims the configured lease so that only one pod across the cluster runs the DELETEs per tick.

func (*CleanupWorker) Start

func (w *CleanupWorker) Start(ctx context.Context) error

Start launches the background cleanup goroutine. The provided context is used as the parent for all cleanup operations; cancelling it (or calling Stop) shuts down the loop. Tracing is disabled on the derived context so cleanup polling does not generate trace spans.

func (*CleanupWorker) Stop

func (w *CleanupWorker) Stop()

Stop cancels the background context and blocks until the cleanup goroutine has exited. It is safe to call from a deferred shutdown path.

type ConsumeOption

type ConsumeOption func(*ConsumeOptions)

ConsumeOption mutates ConsumeOptions. Pass options to ConsumeMessages to override the per-queue defaults.

func WithConcurrency

func WithConcurrency(n int) ConsumeOption

WithConcurrency sets the number of worker goroutines for the consumer. See ConsumeOptions.Concurrency for the safety requirements.

type ConsumeOptions

type ConsumeOptions struct {
	// Concurrency is the number of worker goroutines processing deliveries for this queue. The default of 1 preserves strict in-order, one-at-a-time processing. Values above 1 are only safe for queues whose messages are independently processable — no cross-message ordering requirements — such as request-log and audit-event persistence, where each message is an independent row and the inbox pattern deduplicates redeliveries.
	Concurrency int
}

ConsumeOptions configures a single queue consumer.

type ConversationRef

type ConversationRef struct {
	ConversationID    string `json:"conversation_id,omitempty"`
	TopicResourceType string `json:"topic_resource_type,omitempty"`
	TopicResourceID   string `json:"topic_resource_id,omitempty"`
}

ConversationRef points a fan-out at a conversation, either directly by ID or by the resource it is anchored to (the system channel is resolved by category when nil).

type CustomerRegisteredData

type CustomerRegisteredData struct {
	// SellerAccountID is the seller/vendor account whose portal the buyer registered on. The support route is resolved against this account.
	SellerAccountID string `json:"seller_account_id"`
	// CustomerAccountID is the buyer's customer account (the notification links to it).
	CustomerAccountID string `json:"customer_account_id"`
	// CustomerName is the customer's display name (best-effort; may be empty for an existing-customer join, in which case the consumer falls back to the number).
	CustomerName string `json:"customer_name,omitempty"`
	// CustomerNumber is the seller-facing customer number.
	CustomerNumber string `json:"customer_number,omitempty"`
	// RegistrantUserID is the user (us_) who registered.
	RegistrantUserID string `json:"registrant_user_id,omitempty"`
	// IsExistingCustomer is true when the buyer joined an existing customer account rather than creating a new one.
	IsExistingCustomer bool `json:"is_existing_customer,omitempty"`
}

CustomerRegisteredData is the payload for CoreEventCustomerRegisteredQueue messages. It identifies a buyer who completed portal registration so the notification-service consumer can notify the seller's customer-service support-route group. All display fields the consumer needs are carried here — the consumer (notification-service) has no core-service client to re-fetch them.

type EmailLogData

type EmailLogData struct {
	// SesMessageID is the unique message identifier returned by SES, used to correlate delivery status events (bounces, complaints) back to this email.
	SesMessageID string `json:"ses_message_id"`
	// To are the recipient addresses, persisted as email_recipient rows so the log lists and searches by who received it.
	To []string `json:"to,omitempty"`
	// AccountID is the account context for audit logging.
	AccountID *string `json:"account_id,omitempty"`
	// SentByID is the agent who triggered the email for audit logging.
	SentByID *string `json:"sent_by_id,omitempty"`
	// Subject is the email subject line, stored in the audit record for quick reference without needing to look up the original template.
	Subject string `json:"subject"`
	// Filename is the name of any attachment included with the email.
	Filename *string `json:"filename,omitempty"`
}

EmailLogData is the payload for NotificationEventEmailLogQueue messages. It carries the metadata needed to create an email audit record after the notification-service has successfully dispatched an email through SES.

type EmailSendData

type EmailSendData struct {
	// To is the list of recipient email addresses.
	To []string `json:"to"`
	// Subject is the email subject line.
	Subject string `json:"subject"`
	// TemplateID identifies which SES email template to render.
	TemplateID constants.EmailTemplate `json:"template_id"`
	// Params are key-value pairs passed to the template engine for variable substitution (e.g. user name, verification link).
	Params map[string]any `json:"params,omitempty"`
	// SendAs overrides the default sender address (e.g. "support@openmrp.ai"). When nil the notification-service uses its configured default sender.
	SendAs *string `json:"send_as,omitempty"`
	// AccountID is the account context for the email, used for audit logging.
	AccountID *string `json:"account_id,omitempty"`
	// SentByID is the agent who triggered the email, used for audit logging.
	SentByID *string `json:"sent_by_id,omitempty"`
	// AttachmentData is the base64-encoded attachment content. When present, the notification-service sends a raw MIME email with the attachment.
	AttachmentData *string `json:"attachment_data,omitempty"`
	// AttachmentFilename is the filename for the attachment.
	AttachmentFilename *string `json:"attachment_filename,omitempty"`
	// AttachmentContentType is the MIME content type for the attachment.
	AttachmentContentType *string `json:"attachment_content_type,omitempty"`
}

EmailSendData is the payload for NotificationCmdSendEmailQueue messages. It describes a single outbound email: the recipients, subject, template, and any template parameters. It is serialized into the contracts.AmqpMessage.Data field before being written to the outbox table.

type Enqueuer

type Enqueuer struct {
	// contains filtered or unexported fields
}

Enqueuer implements the publishing side of the transactional outbox pattern. It runs two background goroutines:

  • pollLoop: on each tick, acquires a batch of pending outbox messages (with optimistic locking), publishes each to RabbitMQ, and marks them as published or failed in the database.
  • cleanupLoop: on each tick, releases expired locks left by crashed instances so those messages become eligible for re-processing.

Start and Stop control the goroutine lifecycle. Tracing is disabled for outbox operations (via appctx.WithNoTrace) to avoid cluttering the trace backend with high-volume background traffic.

func NewEnqueuer

func NewEnqueuer(config *EnqueuerConfig, repo OutboxEnqueuerRepo, broker MessageBroker, l *lease.Lease) (*Enqueuer, error)

NewEnqueuer creates a new outbox enqueuer. Pass a config with at least ServiceName set; zero-value fields are filled with production defaults.

The poll and cleanup loops continue to run on every pod — they coordinate through per-message optimistic locking, and running them in parallel increases publishing throughput and cross-pod recovery of stuck locks. The purge loop is wrapped in a distributed lease so only one pod per service deletes old rows.

func (*Enqueuer) Notify

func (e *Enqueuer) Notify()

Notify wakes the poll loop to drain the outbox immediately rather than waiting for the next (possibly idle-backed-off) tick. Call it AFTER the transaction that wrote the outbox row commits — the row must be visible to the poll query, so kicking from inside the still-open transaction would race the poll and be wasted. It is non-blocking and coalescing: a kick that lands while one is already pending is dropped (the pending wake-up's drain handles every available row), and kicking before Start or after Stop is harmless. Safe for concurrent callers; the nil receiver guard lets callers hold a possibly-unset OutboxNotifier without nil-checking.

func (*Enqueuer) Start

func (e *Enqueuer) Start(ctx context.Context) error

Start launches the poll and cleanup goroutines. The provided context is used as the parent for all background operations; cancelling it (or calling Stop) shuts down both loops. Tracing is disabled on the derived context so outbox polling does not generate trace spans.

func (*Enqueuer) Stop

func (e *Enqueuer) Stop()

Stop cancels the background context and blocks until both the poll and cleanup goroutines have exited. It is safe to call from a deferred shutdown path.

type EnqueuerConfig

type EnqueuerConfig struct {
	// ServiceName (required) identifies which service owns this enqueuer instance. Stamped onto rows this service writes and used in log messages; it does not scope the poll, which claims pending rows from every service sharing the database.
	ServiceName string

	// PlatformMode (optional) is the platform mode. When set to "test", default intervals are minimized so e2e runs observe async side-effects quickly (see WithDefaults).
	PlatformMode constants.PlatformMode

	// LockOwner (optional; default: "{hostname}-{pid}") is a unique identifier for this process instance, used to claim outbox messages via optimistic locking.
	LockOwner string

	// PollInterval (optional; default: 250ms in production, 10ms in test) controls how frequently the enqueuer polls the outbox table for pending messages while there is work to do.
	PollInterval time.Duration

	// MaxPollInterval (optional; default: 30s in production, == PollInterval in test) is the ceiling for idle backoff. When consecutive polls find nothing, the interval doubles from PollInterval up to this value so an empty outbox is not queried at full rate. Any poll that finds work resets the interval to PollInterval, so pickup latency and throughput under load are unchanged; only the steady-state idle poll rate drops. The tradeoff is that the first message after a sustained idle period waits up to MaxPollInterval to be picked up. Must be >= PollInterval (clamped in WithDefaults).
	//
	// The default is deliberately slow because the poll query is not scoped by service_name: every enqueuer on a given database competes for every pending row, so a service that overrides this to a tighter ceiling (notification-service, agent-service) drains the whole table on behalf of the services that do not. Latency-sensitive producers should call Notify() after commit rather than lowering this, and anything that must publish promptly without a kick belongs on a service that overrides the ceiling.
	MaxPollInterval time.Duration

	// BatchSize (optional; default: 100) is the maximum number of outbox messages to lock and publish in a single poll cycle.
	BatchSize int

	// LockDurationSeconds (optional; default: 60) is how long (in seconds) a message remains locked to this enqueuer before the lock expires and the message becomes eligible for another instance to pick up.
	LockDurationSeconds int

	// CleanupInterval (optional; default: 30s) controls how frequently the enqueuer runs its expired-lock cleanup pass, releasing locks held by crashed processes.
	CleanupInterval time.Duration

	// RetryBackoff (optional; default: 1s base, 2x multiplier, 1h max, 25% jitter; in test mode 10ms base, 2x multiplier, 2s max, 10% jitter) configures the exponential backoff with jitter used to compute the delay before retrying a failed outbox message.
	RetryBackoff *retry.Config

	// DBRetryBackoff (optional; default: OutboxDBRetryConfig(PlatformMode) — 3 retries, 25ms initial, 500ms max, 20% jitter in production) configures short retries for transient database lock conflicts while claiming or marking outbox rows.
	DBRetryBackoff *retry.Config

	// RetentionHours (optional; default: 168 i.e. 7 days) is how long published outbox messages are kept before the purge loop deletes them.
	RetentionHours int

	// PurgeInterval (optional; default: 1h) controls how frequently the enqueuer runs its purge loop to delete old published messages.
	PurgeInterval time.Duration

	// PurgeLeaseTTL (optional; default: 5m) bounds how long the purge loop holds its distributed lease. The lease ensures only one pod per service performs the bulk DELETE of published messages each tick.
	PurgeLeaseTTL time.Duration
}

EnqueuerConfig holds the configuration for the outbox enqueuer.

func (*EnqueuerConfig) WithDefaults

func (c *EnqueuerConfig) WithDefaults() *EnqueuerConfig

WithDefaults fills zero-value fields with production defaults and returns the config. It computes a unique lock owner from the hostname and process ID when not set.

type ExportOperation

type ExportOperation string

names one async export, and stems its routing key, queue and inbox handler so those cannot drift apart. Persisted in the inbox: renaming one orphans in-flight records.

const (
	ExportUnits                 ExportOperation = "export_units"
	ExportUnitGroups            ExportOperation = "export_unit_groups"
	ExportProductLines          ExportOperation = "export_product_lines"
	ExportItemCategories        ExportOperation = "export_item_categories"
	ExportDepartments           ExportOperation = "export_departments"
	ExportLocations             ExportOperation = "export_locations"
	ExportMachines              ExportOperation = "export_machines"
	ExportScanningStations      ExportOperation = "export_scanning_stations"
	ExportProductionRuns        ExportOperation = "export_production_runs"
	ExportProductionSteps       ExportOperation = "export_production_steps"
	ExportParts                 ExportOperation = "export_parts"
	ExportProducts              ExportOperation = "export_products"
	ExportMaterials             ExportOperation = "export_materials"
	ExportProperties            ExportOperation = "export_properties"
	ExportHubspotCompanyReviews ExportOperation = "export_hubspot_company_reviews"
	ExportPriceList             ExportOperation = "export_price_list"
)

The canonical export operations

func ExportOperationFor

func ExportOperationFor(resourceSlug string) (ExportOperation, bool)

finds the export command for a resource slug, an export being named for its resource. The second return is false for an unregistered resource, which is a wiring mistake.

func (ExportOperation) Handler

func (o ExportOperation) Handler() string

builds the inbox handler key, which is part of the persisted dedup identity

func (ExportOperation) Queue

func (o ExportOperation) Queue() string

builds the command queue name

func (ExportOperation) RoutingKey

func (o ExportOperation) RoutingKey() contracts.AmqpRoutingKey

builds the AMQP command routing key

func (ExportOperation) String

func (o ExportOperation) String() string

type GenerateProductionScheduleData

type GenerateProductionScheduleData struct {
	AccountID  string `json:"account_id"`
	ScheduleID string `json:"schedule_id"`
	// PlanningAsOf is stamped by the tick, not read at consume time, so a message that sits in the queue still plans against the moment the cadence fired.
	PlanningAsOf time.Time `json:"planning_as_of"`
	// AutoPublish publishes the version as soon as it solves, for merchants who want the cadence to be the whole workflow.
	AutoPublish bool `json:"auto_publish"`
}

GenerateProductionScheduleData is the payload for CoreCmdGenerateProductionScheduleQueue messages. The schedule row already exists in `generating` status when the message is published, so the consumer solves into a row that is already visible rather than creating one — a tick that enqueued and then died would otherwise leave no trace.

type HubspotSyncCommandData

type HubspotSyncCommandData struct {
	// JobID is the type-prefixed HubSpot sync job id (e.g. "igjb_...").
	JobID string `json:"job_id"`
	// AccountID is the account the job belongs to.
	AccountID string `json:"account_id"`
}

HubspotSyncCommandData is the payload for CoreCmdHubspotSyncQueue messages (both preview and execute). It identifies the backfill job to run; the consumer dispatches on the message's routing key.

type InboxCheckResult

type InboxCheckResult struct {
	// IsDuplicate is true when an inbox record already exists for this (message_id, handler) pair.
	IsDuplicate bool
	// AlreadyProcessed is true when the existing record has status "processed", meaning the handler already ran to completion.
	AlreadyProcessed bool
	// HasPreviousError is true when the existing record has a non-nil LastError, indicating the previous attempt failed.
	HasPreviousError bool
	// PreviousError contains the error message from the last failed attempt.
	PreviousError *string
	// ExistingRecord is the full inbox record, available for detailed inspection.
	ExistingRecord *InboxRecord
}

InboxCheckResult contains the result of checking for a duplicate message. It is used internally by the InboxConsumer to decide the outcome for a re-delivered message.

type InboxConsumer

type InboxConsumer struct {
	// contains filtered or unexported fields
}

InboxConsumer wraps message handlers with inbox-based deduplication to achieve exactly-once processing semantics. For each incoming AMQP delivery it:

  1. Extracts the message ID and metadata from the delivery and body.
  2. Attempts to insert an inbox record (status = "received").
  3. If the insert succeeds (new message), the handler is invoked.
  4. If the insert fails with a duplicate-key error, handleDuplicate inspects the existing record's status to decide whether to skip (already processed), retry (previously failed), or retry (crash recovery — received but never completed).

This pattern guarantees that a handler is invoked at most once for a given (message_id, handler) pair under normal operation, and provides safe retry semantics for crash-recovery scenarios.

func NewInboxConsumer

func NewInboxConsumer(repo InboxRepo, serviceName string) *InboxConsumer

NewInboxConsumer creates a new InboxConsumer that uses the given repository for persistence and derives a tracer scoped to "{serviceName}.inbox_consumer".

func (*InboxConsumer) Wrap

func (c *InboxConsumer) Wrap(handler string, fn MessageHandler) MessageHandler

Wrap returns a new MessageHandler that guards fn with inbox deduplication. The handler parameter is a human-readable name that scopes the deduplication — the same message ID processed by different handlers (e.g. "notification.send_email" vs "notification.log_email") is treated as distinct and both execute.

Metadata (message ID, request ID, parent message ID) is extracted from the AMQP delivery headers and body. If no message ID can be found, the handler runs without deduplication (with a warning log) to avoid silently dropping messages.

type InboxPurger

type InboxPurger struct {
	// contains filtered or unexported fields
}

InboxPurger runs a background goroutine that periodically deletes processed inbox records older than the configured retention period. This prevents the message_inbox table from growing unboundedly while preserving recent records for debugging and deduplication.

func NewInboxPurger

func NewInboxPurger(config *InboxPurgerConfig, repo InboxPurgerRepo, l *lease.Lease) (*InboxPurger, error)

NewInboxPurger creates a new inbox purger. A non-nil lease is required so that only one pod per service deletes processed inbox rows each tick.

func (*InboxPurger) Start

func (p *InboxPurger) Start(ctx context.Context) error

Start launches the background purge goroutine. The provided context is used as the parent for all purge operations; cancelling it (or calling Stop) shuts down the loop.

func (*InboxPurger) Stop

func (p *InboxPurger) Stop()

Stop cancels the background context and blocks until the purge goroutine has exited.

type InboxPurgerConfig

type InboxPurgerConfig struct {
	// ServiceName (required) identifies which service owns this purger. It's included in the lease name so each service scopes its purger independently.
	ServiceName string

	// PlatformMode (optional; default: "") shortens the default PurgeInterval to 1m when test (see WithDefaults); otherwise the production 1h default applies.
	PlatformMode constants.PlatformMode

	// RetentionHours (optional; default: 168 i.e. 7 days) is how long processed inbox records are kept before the purge loop deletes them.
	RetentionHours int

	// PurgeInterval (optional; default: 1h) controls how frequently the purger runs its purge loop to delete old processed records.
	PurgeInterval time.Duration

	// BatchSize (optional; default: 1000) is the maximum number of processed inbox records to delete in a single SQL DELETE statement.
	BatchSize int32

	// LeaseTTL (optional; default: 5m) bounds how long the purger holds its lease before a crashed holder's claim expires.
	LeaseTTL time.Duration
}

InboxPurgerConfig holds the configuration for the inbox purger worker.

func (*InboxPurgerConfig) WithDefaults

func (c *InboxPurgerConfig) WithDefaults() *InboxPurgerConfig

WithDefaults fills zero-value fields with production defaults and returns the config.

type InboxPurgerRepo

type InboxPurgerRepo interface {
	// PurgeProcessed deletes processed inbox records older than retentionHours, up to limit rows per call. Returns the number of rows deleted.
	PurgeProcessed(ctx context.Context, retentionHours int, limit int32) (int64, error)
}

InboxPurgerRepo defines the persistence interface used by the InboxPurger to delete processed inbox records that have exceeded the retention period.

type InboxRecord

type InboxRecord struct {
	ID              int64
	MessageID       string
	ServiceName     string
	Handler         string
	MessageType     string
	RequestID       *string
	ParentMessageID *string
	Status          InboxStatus
	Attempts        int
	LastError       *string
	ReceivedAt      time.Time
	ProcessedAt     *time.Time
}

InboxRecord represents a row in the inbox table. It tracks delivery state, attempt count, and any error from the most recent processing attempt. The InboxConsumer reads this record on duplicate detection to decide whether to skip, retry, or trigger crash recovery.

type InboxRecordInput

type InboxRecordInput struct {
	// MessageID is the globally unique identifier from the AMQP MessageId header.
	MessageID string
	// ServiceName identifies which service is consuming the message.
	ServiceName string
	// Handler is the logical handler name (e.g. "notification.send_email") used to scope deduplication — the same message delivered to different handlers is processed independently.
	Handler string
	// MessageType is the AMQP routing key / message type for observability.
	MessageType string
	// RequestID ties this message back to the originating request for tracing.
	RequestID string
	// ParentMessageID links to the message that caused this one to be emitted.
	ParentMessageID string
}

InboxRecordInput contains the data needed to create an inbox record. It is populated from the AMQP delivery metadata and message body by InboxConsumer.Wrap.

type InboxRepo

type InboxRepo interface {
	// TryInsert attempts to insert a new inbox record with status "received". On success it returns the auto-generated record ID. On duplicate (MySQL error 1062 from the unique index on message_id + handler), it returns 0 and the MySQL error so the caller can branch into duplicate-handling logic.
	TryInsert(ctx context.Context, input InboxRecordInput) (int64, error)

	// GetByMessageAndHandler retrieves the existing inbox record for a given message and handler combination. Used by handleDuplicate to inspect the prior record's status and decide whether to skip, retry, or trigger crash recovery.
	GetByMessageAndHandler(ctx context.Context, messageID, handler string) (*InboxRecord, error)

	// MarkProcessed transitions the record to "processed" status and sets processed_at. Called after the handler completes successfully.
	MarkProcessed(ctx context.Context, id int64) error

	// MarkFailed increments the attempt count and stores the error message from the most recent failed attempt. The record stays in "received" status so it can be retried on re-delivery.
	MarkFailed(ctx context.Context, id int64, errMsg string) error
}

InboxRepo defines the persistence interface for inbox-based message deduplication. Implementations are provided by each service's repository layer, backed by the shared message_inbox table.

type InboxStatus

type InboxStatus string

InboxStatus represents the lifecycle state of an inbox record as it moves from initial receipt through successful processing or failure.

const (
	// InboxStatusReceived means the message was inserted into the inbox table but the handler has not yet completed. If the process crashes at this point, the record stays in "received" and the InboxConsumer treats re-delivery as a crash-recovery retry.
	InboxStatusReceived InboxStatus = "received"
	// InboxStatusProcessed means the handler ran to completion and the message should not be processed again. Duplicate deliveries with this status are silently ACKed.
	InboxStatusProcessed InboxStatus = "processed"
)

type InvoiceCreatedReportData

type InvoiceCreatedReportData struct {
	// Names the account the invoice was created for.
	AccountID string `json:"account_id"`
	// Identifies the invoice that triggered the report.
	InvoiceID string `json:"invoice_id"`
}

Carries a created invoice to the usage meter. The meter counts one event per message, so the invoice id travels only for traceability.

type MessageBroker

type MessageBroker interface {
	// PublishMessage publishes the message to the given exchange with the specified routing key.
	PublishMessage(ctx context.Context, exchange, routingKey string, message contracts.AmqpMessage) error
	// ConsumeMessages consumes messages from the given queue and invokes the handler for each delivery.
	ConsumeMessages(ctx context.Context, queueName string, handler MessageHandler, opts ...ConsumeOption) error
	// ConsumeFanout declares a per-instance ephemeral (non-durable, exclusive, auto-delete) queue named "<baseName>.<instance-suffix>", binds it to the given routing keys on the application exchange, and consumes from it. Because every process that calls this gets its OWN queue, each receives a copy of every matching message — true fan-out. This is the correct primitive for realtime WebSocket delivery, where every api-gateway replica holds a distinct set of client sockets and must therefore see every event. Contrast with ConsumeMessages, whose callers share one durable queue and thus compete for deliveries (work-queue semantics). The per-instance queue dies with the process, so undelivered realtime events are simply dropped — acceptable because the persisted rows remain the source of truth.
	ConsumeFanout(ctx context.Context, baseName string, routingKeys []string, handler MessageHandler, opts ...ConsumeOption) error
	// IsReady reports whether the broker connection and channel are ready for use.
	IsReady() bool
	// Close shuts down the AMQP channel and connection. Safe to call multiple times.
	Close()
}

MessageBroker defines the interface for publishing and consuming AMQP messages.

func NewRabbitMQ

func NewRabbitMQ(ctx context.Context, config *RabbitMQConfig) (MessageBroker, error)

NewRabbitMQ creates a new rabbitMQ client connected to the given AMQP URI. It dials the broker with exponential backoff, declares the full exchange/queue topology, and verifies the connection is ready. On success the returned client is guaranteed to have an open connection and channel.

Example

ExampleNewRabbitMQ shows the minimal configuration for connecting to the broker: only URI is required; all other fields receive production defaults.

package main

import (
	"context"

	"github.com/open-mrp/api/shared/messaging"
)

func main() {
	broker, err := messaging.NewRabbitMQ(context.Background(), &messaging.RabbitMQConfig{
		URI: "amqp://guest:guest@rabbitmq:5672/",
	})
	if err != nil {
		panic(err)
	}
	_ = broker
}

type MessageHandler

type MessageHandler func(context.Context, amqp.Delivery) error

MessageHandler is the callback signature for processing a single AMQP delivery. Implementations should return nil on success (the message will be ACKed) or an error on failure (the message will be rejected to the dead-letter queue after retry exhaustion).

type OutboxEnqueuerRepo

type OutboxEnqueuerRepo interface {
	// AcquireAndLock atomically selects up to `limit` pending messages whose next_run_at has passed and locks them to the given lockOwner for lockDurationSeconds. Returns the locked messages for publishing.
	AcquireAndLock(ctx context.Context, lockOwner string, limit int, lockDurationSeconds int) ([]*OutboxMessage, error)

	// MarkPublished updates the given messages' status to 'published' with a timestamp, preserving the records for audit and debugging purposes. The enqueuer calls this once per batch with every id it published, so the implementation should issue a single set-based UPDATE.
	MarkPublished(ctx context.Context, ids []int64) error

	// MarkFailed increments the message's attempt count, records the error message, and schedules the next retry after retryDelaySecs seconds. If MaxAttempts is exceeded the message remains in "failed" status for manual investigation.
	MarkFailed(ctx context.Context, id int64, errorMsg string, retryDelaySecs int) error

	// CleanupExpiredLocks releases locks held by enqueuer instances that have crashed or stalled (lock_expires_at < now), making those messages eligible for re-acquisition by any healthy enqueuer.
	CleanupExpiredLocks(ctx context.Context, limit int32) (int64, error)

	// PurgePublished deletes published outbox messages older than retentionHours, up to limit rows per call. Returns the number of rows deleted.
	PurgePublished(ctx context.Context, retentionHours int, limit int32) (int64, error)
}

OutboxEnqueuerRepo defines the read/update interface used exclusively by the Enqueuer to process outbox messages. It is kept separate from OutboxRepo because the enqueuer operates outside of business transactions and needs different operations (locking, bulk fetch, status updates).

type OutboxMessage

type OutboxMessage struct {
	ID              int64
	MessageID       string
	ServiceName     string
	MessageType     string
	Destination     string
	RoutingKey      string
	Headers         map[string]any
	Payload         contracts.AmqpMessage
	Status          OutboxStatus
	Attempts        int
	MaxAttempts     int
	NextRunAt       time.Time
	LockedAt        *time.Time
	LockOwner       *string
	LockExpiresAt   *time.Time
	LastError       *string
	PublishedAt     *time.Time
	RequestID       *string
	ParentMessageID *string
	CreatedAt       time.Time
	UpdatedAt       time.Time
}

OutboxMessage represents a row in the outbox table as read by the Enqueuer. It includes all columns needed for locking, publishing, retry scheduling, and failure tracking.

type OutboxMessageInput

type OutboxMessageInput struct {
	// MessageID is the globally unique identifier for this message (e.g. mg_abc123).
	MessageID string
	// ServiceName identifies the service that created the message (e.g. "auth-service").
	ServiceName string
	// MessageType categorizes the message for routing and consumer dispatch (e.g. "notification.cmd.send_email").
	MessageType string
	// Destination is the AMQP exchange the message should be published to.
	Destination string
	// RoutingKey is the AMQP routing key used for topic-based queue binding.
	RoutingKey string
	// Payload is the structured message body that will be JSON-serialized and published.
	Payload contracts.AmqpMessage
	// MaxAttempts caps how many times the enqueuer will retry publishing before giving up.
	MaxAttempts int
	// DelaySeconds (optional; default 0 = available immediately) defers first delivery by scheduling next_run_at that many seconds into the future. Used for backoff on re-enqueue (e.g. bounded auto-retry of a transient failure) so the message is not republished into a still-failing dependency. Not every service's outbox Create honors this; today only agent-service does.
	DelaySeconds int
}

OutboxMessageInput contains the data needed to insert a new outbox message. It is passed to OutboxRepo.Create inside the same database transaction as the business operation, ensuring atomicity between the domain write and the intent to publish.

type OutboxNotifier

type OutboxNotifier interface {
	Notify()
}

OutboxNotifier is the producer-facing handle for waking an Enqueuer after an outbox write commits. *Enqueuer satisfies it. Inject it into services that write latency-sensitive outbox rows (e.g. starting an agent chat run) so the row is picked up on the next instant rather than on the next idle poll, which can be as long as MaxPollInterval away.

type OutboxRepo

type OutboxRepo interface {
	// Create inserts a new outbox message within the current transaction. The message starts in "pending" status and will be picked up by the Enqueuer's poll loop.
	Create(ctx context.Context, input OutboxMessageInput) (int64, error)
}

OutboxRepo defines the write-side interface for outbox message persistence. It is used by service-layer code to enqueue a message inside the same database transaction as the business operation (transactional outbox pattern).

type OutboxStatus

type OutboxStatus string

OutboxStatus represents the lifecycle state of an outbox message as it moves from creation through publishing or failure.

const (
	// OutboxStatusPending means the message has been written to the outbox table (inside the service's business transaction) and is waiting for the Enqueuer to pick it up.
	OutboxStatusPending OutboxStatus = "pending"
	// OutboxStatusPublished means the Enqueuer successfully published the message to RabbitMQ and the outbox record has been deleted (or marked) as complete.
	OutboxStatusPublished OutboxStatus = "published"
	// OutboxStatusFailed means the Enqueuer attempted to publish but encountered an error. The attempt count is incremented and the message is scheduled for retry with exponential backoff until MaxAttempts is reached.
	OutboxStatusFailed OutboxStatus = "failed"
)

type RabbitMQConfig

type RabbitMQConfig struct {
	// URI (required) is the rabbitMQ connection URI.
	URI string

	// ConnectionTimeout (optional; default: 2m) is the overall timeout for the initial connection attempt.
	ConnectionTimeout time.Duration

	// MaxRetries (optional; default: 10) is the maximum number of connection dial retries.
	MaxRetries int

	// InitialRetryWait (optional; default: 1s) is the starting backoff interval between connection retries.
	InitialRetryWait time.Duration

	// MaxRetryWait (optional; default: 10s) is the maximum backoff interval between connection retries.
	MaxRetryWait time.Duration

	// PrefetchCount (optional; default: 1) is the QoS prefetch limit per consumer.
	PrefetchCount int

	// ReconnectDelay (optional; default: 5s) is how long to wait before retrying after a consumer failure.
	ReconnectDelay time.Duration
}

RabbitMQConfig represents the configuration for the rabbitMQ client.

func (*RabbitMQConfig) WithDefaults

func (c *RabbitMQConfig) WithDefaults() *RabbitMQConfig

WithDefaults returns a new RabbitMQConfig with all zero-value optional fields replaced by production defaults. It is safe to call on a nil receiver. The original config is not mutated; a copy is always returned.

type RealtimeDeliveryData

type RealtimeDeliveryData struct {
	AccountID string `json:"account_id"`
	// RecipientUserID is the user id (us_) used as the WS user-topic key (the gateway subscribes user:<user_id> from the validated identity's actor id).
	RecipientUserID string `json:"recipient_user_id,omitempty"`
	// RecipientAccountUserID is the per-account recipient (acus_) the notification belongs to.
	RecipientAccountUserID string `json:"recipient_account_user_id,omitempty"`
	// ConversationID targets the per-conversation topic (live chat).
	ConversationID string `json:"conversation_id,omitempty"`
	// AnnouncementID targets the per-account broadcast topic (account:<account_id>).
	AnnouncementID string `json:"announcement_id,omitempty"`
	// Event is the client-facing event name: notification.created | announcement.created | message.created | conversation.updated | unread.changed | account.unread_hint.
	Event string `json:"event"`
	// Visibility is the audience of a message-bearing frame (internal | external | system); empty for non-message events. SAFETY: a customer-subscribed conversation socket must drop frames whose Visibility is "internal" so an internal note is never delivered to the customer. The authoritative guarantee is the visibility-filtered read path; this lets the realtime layer enforce the same.
	Visibility     string          `json:"visibility,omitempty"`
	NotificationID string          `json:"notification_id,omitempty"`
	MessageID      string          `json:"message_id,omitempty"`
	Sequence       int64           `json:"sequence,omitempty"`
	UnreadCount    *int64          `json:"unread_count,omitempty"`
	Payload        json.RawMessage `json:"payload,omitempty"`
}

RealtimeDeliveryData is the payload for NotificationEventDeliveredQueue messages. notification-service emits it; every api-gateway instance consumes it and fans it out to the matching Hub topic (user:<account_user_id> for the bell, conv:<conversation_id> for live chat). Best-effort: the persisted rows remain the source of truth.

type SalesOrderCreatedData

type SalesOrderCreatedData struct {
	// SalesOrderID is the type-prefixed ID of the created order (e.g. "so_...").
	SalesOrderID string `json:"sales_order_id"`
	// AccountID is the owner/seller account the order belongs to.
	AccountID string `json:"account_id"`
	// BuyerAccountID is the customer account the order was created for.
	BuyerAccountID string `json:"buyer_account_id"`
	// Number is the human-facing order number.
	Number string `json:"number"`
	// StatusCode is the order's status at creation (e.g. "estimate").
	StatusCode string `json:"status_code"`
}

SalesOrderCreatedData is the payload for CoreEventSalesOrderCreatedQueue messages. It identifies a newly created sales order so consumers can run out-of-band side effects (e.g. CRM sync). Consumers re-fetch the full order by ID when they need more than these identifiers.

type SalesOrderShippingUpdatedData

type SalesOrderShippingUpdatedData struct {
	// SalesOrderID is the type-prefixed ID of the updated order.
	SalesOrderID string `json:"sales_order_id"`
	// AccountID is the owner/seller account the order belongs to.
	AccountID string `json:"account_id"`
}

SalesOrderShippingUpdatedData is the payload for CoreEventSalesOrderShippingUpdatedQueue messages. It identifies an order whose carrier / service level / ship-to changed; the consumer re-fetches the order and syncs its shipment records to the order's current shipping fields.

type SeatChangeReportData

type SeatChangeReportData struct {
	// AccountID is the account whose seat count changed.
	AccountID string `json:"account_id"`
}

SeatChangeReportData is the payload for BillingCmdReportSeatChangeQueue messages. It identifies the account whose seat count change should be reported to the billing provider's usage meters.

type SeatSyncData

type SeatSyncData struct {
	// AccountID is the account whose seat count changed.
	AccountID string `json:"account_id"`
}

SeatSyncData is the payload for BillingCmdSyncSeatsQueue messages. It identifies the account whose seat count should be reconciled with the billing provider.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL