Skip to main content

Communication Preferences Module

Manages per-entity communication channel preferences. Controls which channels (email, SMS, WhatsApp, Messenger) and communication types (invoices, payment reminders, etc.) an entity has opted into or out of.

Domain Concepts

  • Entity: Any identifiable record (customer, user, business_user) identified by (entityType, entityId, businessId).
  • Channel toggles: Global on/off flags per channel (emailEnabled, smsEnabled, whatsappEnabled, messengerEnabled).
  • Type-specific preferences: A JSON object mapping communication types to arrays of allowed channels (e.g. { "invoice": ["email"], "payment_reminder": ["email", "sms"] }).
  • Opt-out: A timestamp (optedOutAt) + optional reason that blocks all communications regardless of channel flags.
  • Preferred contacts: Explicit contact details per channel (preferredEmail, preferredPhone, etc.).
  • Fail-open default: If no preference record exists for an entity, all channels are considered enabled.

Architecture

Follows hexagonal (ports & adapters) architecture:

communication-preferences/
├── communication-preferences.module.ts
├── domain/
│ └── communication-preferences-repository.domain.ts # Port (interface + DI token)
├── application/
│ └── communication-preferences.service.ts # Use cases
├── infrastructure/
│ └── communication-preferences.repository.ts # Kysely adapter
└── interfaces/
├── communication-preferences.controller.ts # HTTP adapter
├── dtos/
│ ├── create-preference.dto.ts
│ └── update-preference.dto.ts
└── query/
└── paginate-communication-preferences.query.ts

Dependency flow: Controller → Service → Repository (via DI token COMMUNICATION_PREFERENCES_REPOSITORY).

API Endpoints

MethodPathDescription
POST/communication-preferencesCreate a preference record
GET/communication-preferencesList all (paginated, requires businessId)
GET/communication-preferences/:entityType/:entityId?businessId=Get by entity (returns defaults if none exist)
PATCH/communication-preferences/:entityType/:entityId?businessId=Upsert preferences
DELETE/communication-preferences/:idDelete by ID (HTTP 204)

Key Use Case: canSend()

The service exposes canSend(entityType, entityId, businessId, channel, type?) used by other modules (communications queue, template engine) to check whether a message can be sent:

  1. If no preferences exist → allow (fail-open)
  2. If entity has opted out (optedOutAt set) → block
  3. If channel toggle is falseblock
  4. If type is provided and type-specific preferences exist → check if channel is in the allowed list
  5. Otherwise → allow

Database

Table: communication_preference

ColumnTypeDescription
idUUID (PK)Auto-generated
business_idUUID (FK)Scopes to business
entity_typevarchare.g. "customer", "user"
entity_idUUIDFK to the entity
email_enabledbooleanDefault true
sms_enabledbooleanDefault true
whatsapp_enabledbooleanDefault true
messenger_enabledbooleanDefault true
preferencesjsonbType → channels map
preferred_emailvarcharPreferred email
preferred_phonevarcharPreferred phone
preferred_whatsappvarcharPreferred WhatsApp
preferred_messenger_idvarcharPreferred Messenger ID
opted_out_attimestampWhen opted out
opted_out_reasonvarcharWhy opted out
created_attimestampAuto-set
updated_attimestampSet on update

Composite lookup: (entity_type, entity_id, business_id).

  • communications — sends actual messages; calls canSend() before dispatch
  • communication-templates — manages message templates
  • communication-queue — queues and retries messages
  • business-communication-config — business-level config (per-type, per-channel)

Design Decisions

  1. Fail-open — missing preferences default to "all enabled" rather than blocking. This avoids silent communication failures for new entities.
  2. Upsert on PATCH — the PATCH /:entityType/:entityId endpoint creates a record if none exists, reducing friction for clients.
  3. JSON preferences column — type-specific preferences are stored as JSONB rather than a separate table, keeping the schema simple for a sparse feature matrix.
  4. DI token injection — the repository is injected via COMMUNICATION_PREFERENCES_REPOSITORY token so the service depends on the interface, not the implementation.