Communication Service

communication-service is a Quarkus service that owns all outbound and in-app notifications for ShopSTAR3. Storage is PostgreSQL with store_id as a discriminator column for multi-tenant isolation. Unread in-app notification counts are cached in Redis per {recipient_id, store_id}. All external provider credentials are stored in Vault — they are never written to the database.

Channel Model#

Channels are open VARCHAR strings, not a closed enum. The platform ships a set of well-known constants — EMAIL, SMS, WHATSAPP, PUSH, IN_APP — but any string is a valid channel provided a matching adapter is registered.

ChannelProvider Interface#

All channel adapters implement a single interface:

interface ChannelProvider {
    SendResult send(OutboundMessage message);
}

A ChannelProviderRegistry maps each channel string to its adapter at runtime. To add a custom channel (e.g. SLACK, TELEGRAM, LINE, CUSTOM_WEBHOOK), deploy a new class implementing ChannelProvider and call registry.register() at startup. No database schema migration is required.

An attempt to save a routing configuration referencing an unregistered channel is rejected at the API layer with HTTP 400. The check is registry.isRegistered(channel) before the row is persisted.

Built-in Adapters#

ChannelAdapters
EMAILSendGridAdapter, AwsSesAdapter
SMSTwilioSmsAdapter, UnifionicAdapter
WHATSAPPTwilioWhatsAppAdapter, ThreeSixtyDialogAdapter
PUSHFcmAdapter, ApnsAdapter
IN_APPInternal — INSERT directly into in_app_notifications table; no external provider call

Delivery Modes#

Mode 1 — Synchronous gRPC#

Used for critical, time-sensitive messages where the caller must know delivery was accepted before continuing: password reset links, GDPR export-ready notifications, critical staff alerts.

The caller invokes the SendTransactional gRPC method and waits for a SendResult before proceeding.

Mode 2 — Asynchronous Kafka (Fire-and-Forget)#

Messages are published to one of three priority-partitioned topics. Consumer thread counts are sized to their expected throughput and latency tolerance.

TopicConsumer ThreadsUse Cases
notification.requested.critical8Critical-path messages dispatched asynchronously
notification.requested.transactional16Order lifecycle events, loyalty points earned
notification.requested.marketing4Cart recovery, review requests, campaigns

Event contract:

{
  "type": "string",
  "recipientId": "uuid",
  "storeId": "uuid",
  "payload": {},
  "idempotencyKey": "string",
  "priority": "CRITICAL | TRANSACTIONAL | MARKETING"
}

Mode 3 — Deferred Dispatch#

Deferred messages use the same Kafka contract with three additional fields:

FieldTypePurpose
sendAtTIMESTAMPTZWhen to dispatch the message
expiresAtTIMESTAMPTZSafety net — if still PENDING at expiry, mark EXPIRED rather than send stale
correlationIdVARCHARGroups related deferred steps for bulk cancellation

On receipt of a deferred event, communication-service INSERTs a row into scheduled_notifications with status PENDING. An internal scheduler job polls rows where send_at <= now() and status = PENDING, then dispatches them through the standard dispatch flow.

Mode 4 — Deferred Cancellation#

When a triggering event is no longer relevant, the originating service publishes a notification.cancelled event carrying the correlationId. Communication-service marks all matching PENDING rows in scheduled_notifications as CANCELLED.

The expiresAt field is a passive safety net: rows that are still PENDING past their expiry are marked EXPIRED by the scheduler and never sent.

Who publishes notification.cancelled:

Cancelled NotificationPublisherTrigger
Cart recovery (all steps)marketing-serviceorder.placed
Review requestreview-serviceReview submitted
Payment pending reminderorder-serviceOrder confirmed or cancelled
Back-in-stock alertinventory-serviceinventory.back_in_stock

Data Model#

channel_provider_configs#

Per-store, per-channel provider binding. Columns: config_id, store_id, channel VARCHAR, provider_type VARCHAR, is_active, from_address, settings JSONB. UNIQUE constraint on (store_id, channel). Credentials are stored in Vault and referenced by provider_type, not stored in this table.

notification_routing#

Store-configurable routing rules that determine which channels are active for each message type. Columns: routing_id, store_id (NULL = platform default), message_type VARCHAR, channel VARCHAR, enabled BOOLEAN, priority VARCHAR.

Resolution: if a row exists for (store_id, message_type, channel), it overrides the platform default row for the same (message_type, channel).

message_templates#

Template definitions per message type, channel, and locale. Columns: template_id, store_id (NULL = platform default), message_type, channel VARCHAR, locale VARCHAR(10), subject VARCHAR, body TEXT. The body field uses {{variable}} placeholders for payload injection.

Template resolution order (first match wins):

  1. Store override — exact locale match
  2. Store override — default locale
  3. Platform default — exact locale match
  4. Platform default — "en"

customer_consents#

Per-customer, per-store, per-channel consent record. The channel column is VARCHAR, consistent with the open channel model. Status values: OPTED_IN, OPTED_OUT, NOT_SET.

  • Transactional messages: consent check is bypassed entirely.
  • Marketing messages: dispatched only if status is OPTED_IN. Otherwise the message is logged as SKIPPED with skip_reason = OPTED_OUT.

scheduled_notifications#

Stores deferred and multi-step drip campaign records. Key columns:

ColumnTypeNotes
idUUID PK
store_idUUID
correlation_idVARCHARGroups related steps for cancellation
message_typeVARCHAR
stepINTPosition within a multi-step drip sequence
recipient_idUUID
payloadJSONB
idempotency_keyVARCHAR UNIQUEPrevents duplicate dispatch
statusENUMPENDING / SENT / CANCELLED / EXPIRED
cancel_reasonVARCHARPopulated on cancellation
send_atTIMESTAMPTZDispatch threshold
expires_atTIMESTAMPTZSafety-net expiry

Indexes: (correlation_id, status) for bulk cancellation lookups; (send_at, status) for scheduler polling.

push_tokens#

ColumnNotes
customer_id
store_id
platformFCM or APNS
tokenRaw device token
device_idUsed as the deregistration key
last_used_atUpdated on each successful push

Tokens are registered on customer login and deregistered on logout. Invalid tokens (rejected by FCM or APNs) are pruned on the first provider failure.

in_app_notifications#

ColumnNotes
notification_idUUID PK
store_id
recipient_id
recipient_typeCUSTOMER or STAFF
title
body
link_urlNullable
is_readBOOLEAN
created_at
read_atNullable

Unread counts are maintained in Redis per {recipient_id, store_id} and incremented on INSERT / decremented on read.

message_log#

Audit record for every dispatched or skipped message. Raw PII (email address, phone number) is never stored in this table — only opaque identifiers.

ColumnNotes
message_idUUID PK
store_id
customer_idOpaque reference only — no raw PII
channelVARCHAR
message_typeVARCHAR
categoryTRANSACTIONAL / MARKETING / OPERATIONAL
provider_typeVARCHAR
provider_message_idReturned by the external provider
idempotency_keyVARCHAR UNIQUE
statusQUEUED / SENT / DELIVERED / FAILED / BOUNCED / SKIPPED
skip_reasonOPTED_OUT / NO_TOKEN / NO_ADDRESS / UNKNOWN_CHANNEL
template_idFK to message_templates
timestampsCreated, sent, delivered

Rows are purged when a customer.erasure_requested event is received.

Dispatch Flow#

The following sequence applies to all non-deferred messages, regardless of whether the trigger arrived via Kafka or synchronous gRPC.

  1. Receive trigger — Kafka notification.requested.* consumer or gRPC SendTransactional handler.
  2. Idempotency check — if message_log already contains idempotencyKey with status SENT or QUEUED, skip processing and return immediately.
  3. Deferred branch — if sendAt is present, INSERT a PENDING row into scheduled_notifications and return. The scheduler will dispatch it later.
  4. Resolve recipient contact — call customer-service via gRPC to retrieve email address, phone number, locale, and push tokens.
  5. Resolve routing — query notification_routing for (store_id, messageType) to obtain the list of enabled channels and their priorities.
  6. Consent check — for each channel: if category = TRANSACTIONAL, skip check; if category = MARKETING, verify OPTED_IN or log SKIPPED.
  7. Resolve template — apply the four-step resolution order (store override → platform default, locale-aware).
  8. Inject variables — render {{variable}} placeholders using payload fields and customer context.
  9. Dispatch — call ChannelProviderRegistry.resolve(channel).send(message). For IN_APP, INSERT directly into in_app_notifications; no external provider is called.
  10. Write message_log — record status SENT or FAILED. When the provider delivery webhook fires, UPDATE status = DELIVERED.

gRPC Interface#

MethodSignaturePurpose
SendTransactional(type, recipientId, storeId, payload, idempotencyKey)SendResultSynchronous send for critical messages
RegisterPushToken(customerId, storeId, platform, token, deviceId)RegisterResultRegister a device push token on login
DeregisterPushToken(customerId, storeId, deviceId)DeregisterResultRemove a device token on logout
GetNotifications(recipientId, storeId, page, pageSize)NotificationsResponsePaginated in-app notification list
MarkNotificationsRead(notificationIds[])MarkReadResultMark notifications read; updates Redis unread count

REST Interface (Admin SPA)#

Full CRUD over REST for: template management (per store, per channel, per locale), channel provider configuration, notification routing rules, bulk campaign creation and scheduling, consent management, message log (read-only), and push token management.

Kafka Topics#

Consumed#

TopicAction
notification.requested.criticalDispatch with 8-thread consumer group
notification.requested.transactionalDispatch with 16-thread consumer group
notification.requested.marketingDispatch with 4-thread consumer group
notification.cancelledMark matching PENDING scheduled_notifications as CANCELLED by correlationId
customer.erasure_requestedPurge all message_log and in_app_notifications rows for the customer

Published#

TopicPayload
communication.sentmessageId, channel, messageType, customerId, storeId
communication.failedmessageId, channel, messageType, customerId, storeId, reason

Message Type Registry#

Message types (ORDER_CONFIRMED, CART_RECOVERY, etc.) are not hardcoded in communication-service. Domain services declare their own message types, and communication-service learns about them at runtime.

How types are registered#

Each domain service implements the MessageTypeProvider interface (from ss3-quarkus). On startup, the extension’s NotificationMessageTypeRegistrar calls the RegisterMessageTypes gRPC method on communication-service with all declared types in a single batch. Communication-service upserts them into message_type_definitions and loads them into an in-memory MessageTypeRegistry.

The in-memory registry is seeded from the DB at communication-service startup, so registered types survive restarts without waiting for domain services to re-register.

What a message type declaration carries#

FieldPurpose
typeMachine-readable key — SCREAMING_SNAKE_CASE, e.g. ORDER_CONFIRMED
nameHuman-readable label shown in admin routing and template screens
descriptionDescribes when the notification fires
defaultPriorityCRITICAL, TRANSACTIONAL, or MARKETING — pre-populates routing config
categoryTRANSACTIONAL or MARKETING — determines whether consent is checked
payloadKeysVariables the publisher includes (e.g. orderId, total) — used to validate template {{variables}} at save time

Unknown types → dead-letter#

If a notification.requested.* event arrives with a type not present in the registry, it is routed to notification.requested.dlq rather than processed. An ops-alerting consumer monitors the DLQ and fires an alert on any arrival. This catches typos and services that publish before registering.

Template variable validation#

When a store admin saves a template, communication-service checks that every {{variable}} in the body and subject exists in the payloadKeys declared for that message type. Templates referencing undeclared variables are rejected at save time.

Channel Registry#

Channels are self-registering. Each ChannelProvider adapter calls ChannelProviderRegistry.register() at CDI startup, passing a ChannelDefinition alongside the adapter instance. The registry persists the definition to a channel_definitions table so the admin UI has a stable source of truth across restarts.

Channel definition metadata#

FieldPurpose
channelOpen string key — e.g. EMAIL, SLACK
nameHuman-readable label in admin UI
descriptionWhat the channel does
requiresProviderConfigWhether the channel needs store-level credentials (EMAIL yes; IN_APP no)
contentTypeSUBJECT_AND_BODY (email), BODY_ONLY (SMS/push), STRUCTURED_PAYLOAD (in-app/webhook) — controls which template fields the editor shows
isBuiltIntrue for platform-shipped channels; built-in channels cannot be removed via admin UI

Admin UI discovery#

GET /api/v1/message-types    list all registered types (filterable by category)
GET /api/v1/channels         list all registered channels

These power the routing configuration screen (select type + channel + priority), the template editor (pre-populate available {{variables}}), and the provider config screen (show only channels where requiresProviderConfig = true).

Vault Configuration#

Channel provider credentials are not in Vault. They are per-store, per-channel configuration — store admins supply their own provider accounts (SendGrid API key, Twilio credentials, etc.) via the admin UI, and they are stored in channel_provider_configs. Vault holds only the service’s own infrastructure credentials.

ss3/kv/communication-service/
  db.url
  db.username
  db.password