Skip to main content

DOMAIN_MODEL — notification-service

Sibling: SERVICE_OVERVIEW · APPLICATION_LOGIC · DATA_MODEL · EVENT_SCHEMAS

Strategic anchors: 02 Enterprise Architecture · 06 Data Models · standards/NAMING · standards/ERROR_CODES

The domain layer of notification-service lives under src/domain/ and depends on nothing outside @ghasi/domain-primitives and the standard library. No NestJS, no Drizzle, no fetch, no vendor SDK. Every invariant in this document is enforced here, in pure TypeScript, with branded value objects rejecting invalid construction at the boundary.


1. Branded identifiers

import type { Branded } from '@ghasi/domain-primitives';

export type NotificationId = Branded<string, 'NotificationId'>; // ntf_<ulid>
export type TemplateId = Branded<string, 'TemplateId'>; // tpl_<ulid>
export type TemplateVersionId = Branded<string, 'TemplateVersionId'>; // tpv_<ulid>
export type RecipientId = Branded<string, 'RecipientId'>; // rcp_<ulid>
export type SuppressionRecordId = Branded<string, 'SuppressionRecordId'>; // sup_<ulid>
export type ChannelId = Branded<string, 'ChannelId'>; // ch_<ulid>
export type ChannelCredentialId = Branded<string, 'ChannelCredentialId'>; // chc_<ulid>
export type WebhookInboundId = Branded<string, 'WebhookInboundId'>; // whi_<ulid>
export type DispatchBatchId = Branded<string, 'DispatchBatchId'>; // dbt_<ulid>
export type OptOutTokenId = Branded<string, 'OptOutTokenId'>; // out_<ulid>
export type DeliveryAttemptId = Branded<string, 'DeliveryAttemptId'>; // composite ULID
export type TemplateKey = Branded<string, 'TemplateKey'>; // 'reservation.confirmed.email'

// Re-exported from upstream domains (reference only — never constructed here)
export type TenantId = Branded<string, 'TenantId'>;
export type PropertyId = Branded<string, 'PropertyId'>;
export type ReservationId = Branded<string, 'ReservationId'>;
export type GuestId = Branded<string, 'GuestId'>;
export type UserId = Branded<string, 'UserId'>;
export type FolioId = Branded<string, 'FolioId'>;
export type KeyCredentialId= Branded<string, 'KeyCredentialId'>;

All from* factories validate the prefix and ULID body and throw InvalidIdError on mismatch. The repository layer accepts only branded types; raw strings are rejected.


2. Value objects

2.1 Channel enum and ChannelKind

export type ChannelKind =
| 'email' // SendGrid primary, Mailgun fallback
| 'sms' // Twilio primary, Africa's Talking fallback (Phase 4)
| 'whatsapp' // WhatsApp Business API primary, Meta Cloud API fallback
| 'push_mobile'// FCM (Android) + native APNs (iOS)
| 'push_web' // Web Push (VAPID)
| 'inapp' // WebSocket + REST feed
| 'voice'; // Twilio Voice (Phase 3+)

2.2 NotificationCategory

export type NotificationCategory =
| 'transactional' // booking confirmations, modifications, check-in/out
| 'operational' // staff alerts, work-order assignments, vendor SMS
| 'security' // password reset, MFA, suspicious login (CANNOT BE OPTED OUT)
| 'compliance' // KYC, regulatory notices, GDPR/local-equivalent (CANNOT BE OPTED OUT)
| 'mobile_key' // mobile-key delivery (treated as transactional but with one-time-link semantics)
| 'invoice' // invoice/receipt delivery
| 'dunning' // payment-failure recovery sequence (tenant subscription billing)
| 'marketing' // promotional blasts (always opt-out-respecting)
| 'reminder'; // pre-arrival reminders, post-stay thank-you, review requests

2.3 NotificationPriority

export type NotificationPriority = 'low' | 'normal' | 'high' | 'critical';

critical overrides recipient quiet hours; only security and mobile_key (when active stay) qualify.

2.4 RecipientAddress (sum type)

export type RecipientAddress =
| { kind: 'email'; email: EmailAddress; displayName?: string }
| { kind: 'phone_sms'; e164: E164Phone; countryCode: ISO3166Alpha2 }
| { kind: 'phone_whatsapp'; e164: E164Phone; countryCode: ISO3166Alpha2 }
| { kind: 'push_token'; token: string; platform: 'fcm' | 'apns' | 'web' }
| { kind: 'inapp'; userId: UserId }
| { kind: 'voice'; e164: E164Phone; countryCode: ISO3166Alpha2 };

export type EmailAddress = Branded<string, 'EmailAddress'>; // RFC 5322 lowercased
export type E164Phone = Branded<string, 'E164Phone'>; // /^\+[1-9]\d{6,14}$/
export type ISO3166Alpha2= Branded<string, 'ISO3166Alpha2'>; // 'AF','PK','IR','TJ','AE','SA','GB',…

The factory enforces:

  • email: lower-cases the local-part-preserved form and validates against the RFC 5322 simplified regex.
  • e164: strict + prefix + 7..15 digits.
  • countryCode: must be in the canonical ISO 3166-1 alpha-2 set; used by the dispatcher to resolve the per-(tenant, country) sender-ID.

2.5 Locale and the fallback chain

export type Locale = Branded<string, 'Locale'>; // BCP-47: 'ps-AF','fa-AF','fa-IR','ar-SA','ur-PK','en','fr','tr'

export const LOCALE_FALLBACK: Record<string, Locale[]> = {
'ps-AF': ['ps-AF', 'fa-AF', 'en'].map(asLocale),
'fa-AF': ['fa-AF', 'fa-IR', 'ar-SA', 'en'].map(asLocale),
'fa-IR': ['fa-IR', 'fa-AF', 'ar-SA', 'en'].map(asLocale),
'ar-SA': ['ar-SA', 'ar-AE', 'en'].map(asLocale),
'ar-AE': ['ar-AE', 'ar-SA', 'en'].map(asLocale),
'ur-PK': ['ur-PK', 'en'].map(asLocale),
'en': ['en'].map(asLocale),
'fr': ['fr', 'en'].map(asLocale),
};

TemplateRenderer walks the chain until it finds a LocalizedBody for the requested locale; if none, raises MELMASTOON.NOTIFICATION.TEMPLATE_NOT_FOUND (the trigger of the platform-wide fallback policy: never silently render in the wrong language).

2.6 TextDirection

export type TextDirection = 'rtl' | 'ltr';
export function dirOf(locale: Locale): TextDirection { /* ps-AF, fa-*, ar-*, ur-PK → 'rtl' */ }

2.7 RenderedMessage

export interface RenderedMessage {
channel: ChannelKind;
locale: Locale;
direction: TextDirection;
subject?: string; // email/push only
bodyText: string; // always present
bodyHtml?: string; // email only
previewText?: string; // email preview header
attachments?: Attachment[];// email only
pushPayload?: { title: string; body: string; data: Record<string, string>; deepLink?: string };
whatsappTemplateRef?: { name: string; languageCode: string; components: Record<string, unknown>[] }; // pre-approved template ref
voiceSsml?: string; // voice only; SSML-wrapped TTS
renderWarnings: string[];
}

export interface Attachment {
filename: string;
contentType: string;
uri: string; // GCS URI (preferred); inline blob is rejected at the port
sizeBytes: number;
checksumSha256: string;
}

2.8 AIProvenance (re-exported, never constructed here)

Defined in @ghasi/ai-provenance and persisted on any AI-derived TemplateVersion or AI-personalised RenderedMessage. See AI_INTEGRATION §2.

2.9 SuppressionReason

export type SuppressionReason =
| 'hard_bounce'
| 'soft_bounce_threshold' // 3 soft bounces within 30 days
| 'complaint' // explicit spam complaint or one-click unsubscribe via email body
| 'manual_block' // tenant admin manually suppressed
| 'invalid_address' // E.164 / RFC 5322 validation failure
| 'opt_out' // recipient unsubscribed via signed token
| 'minor_protection' // recipient flagged as minor, channel routed to guardian
| 'regulatory_hold'; // jurisdictional hold (e.g., do-not-call list match)

2.10 Sender

export interface Sender {
channel: ChannelKind;
forCountryCode?: ISO3166Alpha2; // null for email (domain-based) and inapp
identifier: string; // email: 'no-reply@gm-grandhotel.af'; sms: 'GHASI'; whatsapp: '+93791000000'; push: tenant FCM project id
displayName?: string;
verifiedAt?: ISODate; // domain DKIM verification, sender-ID PTA registration, etc.
vendor: string; // 'sendgrid','twilio','meta_cloud_api','fcm'
registrationRefs?: Record<string, string>; // PTA sender-ID id, Meta WABA id, etc.
}

The SenderResolver domain service derives the right Sender for a given (tenant, recipient.countryCode, channel).

2.11 RateLimit

export interface RateLimit {
scope: 'tenant_channel_day' | 'recipient_channel_day' | 'tenant_template_day';
limit: number;
windowStart: ISODate;
current: number;
}

3. Aggregate: Notification (root)

3.1 Shape

export interface Notification {
readonly id: NotificationId;
readonly tenantId: TenantId;
readonly recipientId: RecipientId;
readonly recipientAddress: RecipientAddress;
readonly channel: ChannelKind;
readonly category: NotificationCategory;
readonly priority: NotificationPriority;

readonly templateId: TemplateId;
readonly templateKey: TemplateKey;
readonly templateVersionId: TemplateVersionId;
readonly templateVersionSemver: SemVer; // e.g., '1.4.0'
readonly locale: Locale;
readonly variables: Record<string, JsonValue>; // input variables
readonly renderSnapshot: RenderedMessage; // frozen at enqueue

readonly correlationId: string;
readonly sourceEvent?: { type: string; id: string }; // event-driven origin
readonly initiatedBy?: { kind: 'user' | 'service' | 'scheduler'; id: string };

status: NotificationStatus;
scheduledFor?: ISODate;
attempts: DeliveryAttempt[];
suppressionReason?: SuppressionReason;
digestBatchId?: DispatchBatchId;
aiProvenance?: AIProvenance; // present iff content was AI-personalised

dispatchedAt?: ISODate;
deliveredAt?: ISODate;
openedAt?: ISODate;
clickedAt?: ISODate;
failedAt?: ISODate;
createdAt: ISODate;
updatedAt: ISODate;
expiresAt: ISODate; // body retention TTL
version: number; // OCC

// Optional one-time-token references for mobile-key category
mobileKeyTokenRef?: { keyCredentialId: KeyCredentialId; tokenHash: string; expiresAt: ISODate };

readonly retentionClass: 'operational' | 'regulated' | 'audit';
}

export type SemVer = Branded<string, 'SemVer'>; // /^\d+\.\d+\.\d+$/

export interface DeliveryAttempt {
readonly id: DeliveryAttemptId;
readonly attemptNumber: number; // 1..6
readonly vendor: string;
readonly vendorMessageId?: string;
readonly requestedAt: ISODate;
readonly respondedAt?: ISODate;
readonly outcome: 'accepted' | 'rejected' | 'timeout' | 'bounced' | 'complained' | 'delivered';
readonly errorCode?: string;
readonly errorMessage?: string; // redacted, safe to log
readonly latencyMs?: number;
readonly httpStatus?: number;
readonly retryAfterSeconds?: number; // honored from vendor 429 / Retry-After
}

3.2 State machine

┌──────────────────────────────────────────────────────┐
│ ▼
┌─────────┐ pref/supp ┌────────────┐ ┌────────────┐
create() → │ pending │──suppress(r)──▶ │ suppressed │ (terminal) │ failed │
└────┬────┘ └────────────┘ └────────────┘
│ accept ▲
▼ │
┌────────────┐ schedule() ┌──────────────┐ retries │
│ requested │──────────────────▶ │ scheduled │ exhausted│
└────┬───────┘ └──────┬───────┘ │
│ │ │
│ enqueue │ tick (run_after≤now)
▼ ▼ │
┌───────────┐ dispatch() ┌──────────────┐ │
│ queued │────────────────▶│ dispatching │──reject──────▶│
└───────────┘ └──────┬───────┘ │
│ │
│ vendor accept │
▼ │
┌──────────────┐ │
│ dispatched │──vendor bounce┴──▶ ┌──────────┐
└──────┬───────┘ │ bounced │
│ └──────────┘
│ vendor delivered (terminal)

┌──────────────┐
│ delivered │── open pixel ──▶ opened ──▶ clicked
└──────────────┘

Legal transitions:

FromToTriggerGuard
pendingrequestedaccept()preference + suppression gate passed
pendingsuppressedsuppress(reason)preference / suppression / opt-out hit
requestedscheduledschedule(at)at > now()
requestedqueuedenqueue()scheduledFor is null
scheduledqueuedtick()now() >= scheduledFor
queueddispatchingdispatch()rate-limit OK; sender resolved
dispatchingdispatchedvendorAccepted(attempt)vendor returned 2xx
dispatchingqueuedvendorRejectedRetryable(attempt)attempts.length < 6
dispatchingfailedvendorRejectedTerminal(attempt)non-retryable; or attempts.length >= 6
dispatcheddeliveredmarkDelivered(at)webhook event received
dispatchedbouncedmarkBounced(reason)webhook event received
deliveredopenedmarkOpened(at)email open pixel; channel = email only
openedclickedmarkClicked(linkIndex)tracked link wrap; email only

Re-sending a delivered/failed/suppressed/bounced notification creates a new sibling with parentNotificationId reference; the original is immutable.

3.3 Invariants

  1. tenantId, recipientId, templateVersionId, channel, category are immutable post-construction.
  2. recipientAddress.kind matches channel: emailemail; sms/voicephone_sms/voice; whatsappphone_whatsapp; push_mobile/push_webpush_token; inappinapp. Construction throws MELMASTOON.NOTIFICATION.RECIPIENT_INVALID otherwise.
  3. attempts.length <= 6 for non-inapp; inapp accepts at most 1 attempt.
  4. Any dispatch() requires a non-null resolved Sender; the application service injects it. Domain throws MELMASTOON.NOTIFICATION.SENDER_ID_MISSING if not provided.
  5. category in {security, compliance} cannot be combined with suppress(reason='opt_out'). Construction throws MELMASTOON.NOTIFICATION.CHANNEL_DISABLED is masked — these categories override.
  6. priority='critical' is only accepted for security and mobile_key categories.
  7. retentionClass is derived: regulated for transactional|invoice|mobile_key|security|compliance|dunning; operational for reminder|operational|marketing; audit for any internal-only notification.
  8. Once status is terminal (delivered|failed|bounced|suppressed), the aggregate refuses any state-mutating method except markOpened/markClicked (email-only post-delivery).
  9. aiProvenance is required when templateVersionSemver resolves to a TemplateVersion.source='ai_drafted' — application layer provides it from the source draft.

3.4 Methods (selected)

class NotificationAggregate {
static create(input: NotificationCreateInput): Notification; // pending
accept(): NotificationEvents['notification.requested.v1'];
suppress(reason: SuppressionReason): NotificationEvents['notification.suppressed.v1'];
schedule(at: ISODate): NotificationEvents['notification.scheduled.v1'];
enqueue(): void; // pending → queued
dispatch(sender: Sender, vendorAttempt: DeliveryAttempt): NotificationEvents['notification.dispatched.v1'];
vendorAccepted(attempt: DeliveryAttempt): void; // → dispatched
vendorRejectedRetryable(attempt: DeliveryAttempt): void; // → queued (re-attempt)
vendorRejectedTerminal(attempt: DeliveryAttempt): NotificationEvents['notification.failed.v1'];
markDelivered(at: ISODate, vendorMessageId?: string): NotificationEvents['notification.delivered.v1'];
markBounced(bounceType: 'hard'|'soft', reason: string): NotificationEvents['notification.bounced.v1'];
markOpened(at: ISODate, agent?: string, ipHash?: string): NotificationEvents['notification.opened.v1'];
markClicked(at: ISODate, linkIndex: number, urlHash: string): NotificationEvents['notification.clicked.v1'];
}

4. Aggregate: Template (root) and TemplateVersion (entity)

4.1 Shape

export interface Template {
readonly id: TemplateId;
readonly tenantId: TenantId | null; // null = platform-global
readonly key: TemplateKey; // e.g., 'reservation.confirmed.email'
readonly channel: ChannelKind;
readonly category: NotificationCategory;
readonly defaultPriority: NotificationPriority;
readonly variables: VariableSchema[]; // declared inputs with PII class
readonly retentionClass: 'operational' | 'regulated' | 'audit';

activeVersionId: TemplateVersionId | null; // points to current published version
versions: TemplateVersion[]; // chronological
readonly createdAt: ISODate;
readonly createdBy: UserId;
archivedAt?: ISODate;
archivedBy?: UserId;
successorKey?: TemplateKey; // when archived
}

export interface TemplateVersion {
readonly id: TemplateVersionId;
readonly templateId: TemplateId;
readonly semver: SemVer;
readonly source: 'authored' | 'ai_drafted';
readonly locales: Record<string /* Locale */, LocalizedBody>;
readonly subjectTemplate?: string; // email/push; supports interpolation
readonly previewText?: string; // email only
readonly whatsappTemplateRefs?: Record<string, { name: string; languageCode: string; status: 'pending' | 'approved' | 'rejected' }>;
status: 'draft' | 'active' | 'archived';
aiProvenance?: AIProvenance; // required when source='ai_drafted'
readonly authoredBy: UserId;
readonly createdAt: ISODate;
publishedAt?: ISODate;
publishedBy?: UserId;
approvedBy?: UserId; // HITL gate for AI drafts
approvedAt?: ISODate;
archivedAt?: ISODate;
}

export interface LocalizedBody {
bodyFormat: 'mjml' | 'markdown' | 'plaintext' | 'html' | 'whatsapp_template_ref' | 'ssml';
body: string; // raw source; HTML only after render-time inlining
direction: TextDirection; // derived from locale
fallbackLocale?: Locale;
}

export interface VariableSchema {
name: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'url' | 'money' | 'object';
required: boolean;
description: string;
piiClass: 'none' | 'low' | 'medium' | 'high'; // affects redaction in logs and AI inputs
maxLength?: number;
}

4.2 Invariants

  1. (tenantId, key) is unique. A tenant-overridden template shadows the platform template with the same key.
  2. (templateId, semver) is unique within the template.
  3. At least one LocalizedBody must be present in every TemplateVersion; the active-version's locale set must be a superset of the platform default-locale chain ['ps-AF','fa-AF','ar-SA','en'] for transactional templates (not enforced for tenant overrides — tenants opt in).
  4. Variable names referenced in body/subjectTemplate must be declared in Template.variables (validated at publish()); unknowns raise MELMASTOON.NOTIFICATION.TEMPLATE_VARIABLES_UNDECLARED.
  5. Once TemplateVersion.status = 'active', locales, subjectTemplate, and whatsappTemplateRefs are immutable. Mutations bump semver.
  6. TemplateVersion.source = 'ai_drafted' cannot be published() without (approvedBy, approvedAt) — HITL gate. The application use case raises MELMASTOON.AI.HITL_REQUIRED if absent.
  7. Archiving requires a successorKey if the template is referenced by an active trigger-map entry.
  8. WhatsApp Business templates with whatsappTemplateRefs[locale].status != 'approved' cannot be dispatched. Dispatch raises MELMASTOON.NOTIFICATION.WHATSAPP_TEMPLATE_NOT_APPROVED.

4.3 State machine (TemplateVersion)

draft ──publish() (HITL if ai_drafted)──▶ active ──archive()──▶ archived
│ │
│ supersedeBy(newSemver) │ a new version published with higher semver
▼ ▼ becomes active; this one auto-archives
archived

5. Aggregate: Recipient (root) and RecipientPreferences

5.1 Shape

export interface Recipient {
readonly id: RecipientId;
readonly tenantId: TenantId;
readonly identityKind: 'guest' | 'staff' | 'tenant_admin' | 'vendor' | 'anonymous';
readonly identityRef?: GuestId | UserId; // optional; resolved post-projection
readonly addresses: VerifiedAddress[]; // validated; verified via opt-in
readonly preferences: RecipientPreferences;
readonly minorFlag: boolean; // if DOB known and underage; routes to guardian
readonly guardianRecipientIds: RecipientId[]; // when minorFlag=true
readonly createdAt: ISODate;
readonly updatedAt: ISODate;
readonly version: number;
}

export interface VerifiedAddress {
readonly address: RecipientAddress;
readonly verifiedAt?: ISODate;
readonly verificationMethod?: 'email_link' | 'sms_otp' | 'whatsapp_otp' | 'staff_attested' | 'guest_self_attested';
}

export interface RecipientPreferences {
locale: Locale;
timezone: IANATimezone; // 'Asia/Kabul', 'Asia/Karachi', 'Asia/Tehran'
channels: Record<NotificationCategory, ChannelPreferences>;
quietHours: QuietHours;
globalUnsubscribedAt?: ISODate;
updatedAt: ISODate;
version: number;
}

export interface ChannelPreferences {
email: 'instant' | 'digest' | 'off';
sms: 'instant' | 'off';
whatsapp: 'instant' | 'off';
push_mobile: 'instant' | 'off';
push_web: 'instant' | 'off';
inapp: 'instant' | 'off';
voice: 'instant' | 'off';
}

export interface QuietHours {
enabled: boolean;
startLocal: string; // 'HH:mm', e.g., '22:00'
endLocal: string; // 'HH:mm', e.g., '07:00'
overrideForCritical: boolean; // default true
}

export type IANATimezone = Branded<string, 'IANATimezone'>;

5.2 Invariants

  1. channels.security.{email,sms,inapp} cannot be 'off' (regulatory override).
  2. channels.compliance.{email,inapp} cannot be 'off'.
  3. channels.mobile_key.{whatsapp,sms} cannot both be 'off' while the recipient has any active stay (enforced by application service that consults reservation-service projections).
  4. locale must be a member of LOCALE_FALLBACK keys.
  5. timezone must be a valid IANA name.
  6. quietHours start/end must parse as HH:mm.
  7. If minorFlag = true, guardianRecipientIds.length >= 1.
  8. globalUnsubscribedAt does not silence regulated categories.

5.3 Methods

class RecipientAggregate {
setLocale(locale: Locale): void;
setTimezone(tz: IANATimezone): void;
setChannelPreference(cat: NotificationCategory, channel: ChannelKind, value: 'instant'|'digest'|'off'): void;
setQuietHours(qh: QuietHours): void;
globalUnsubscribe(at: ISODate): NotificationEvents['notification.opted_out.v1'];
resubscribe(at: ISODate): void;
shouldSend(category: NotificationCategory, channel: ChannelKind, atLocal: ISODate): SendDecision;
}

export type SendDecision =
| { kind: 'send' }
| { kind: 'suppress'; reason: SuppressionReason }
| { kind: 'defer'; until: ISODate }; // quiet hours

6. Aggregate: SuppressionRecord (root)

export interface SuppressionRecord {
readonly id: SuppressionRecordId;
readonly tenantId: TenantId;
readonly channel: ChannelKind;
readonly addressHash: string; // sha256(lower(address))
readonly addressCiphertext?: Buffer; // recoverable for admin review (KMS-wrapped)
readonly reason: SuppressionReason;
readonly originatingNotificationId?: NotificationId;
readonly suppressedAt: ISODate;
expiresAt?: ISODate; // null = permanent
overriddenBy?: UserId;
overriddenAt?: ISODate;
}

Invariants:

  • (tenantId, channel, addressHash) unique while not overridden.
  • reason='hard_bounce' is permanent unless explicitly overridden by an admin (audited).
  • reason='soft_bounce_threshold' defaults to expiresAt = +30 days.
  • reason='opt_out' is permanent.

7. Aggregate: Channel (root) and ChannelCredential

export interface Channel {
readonly id: ChannelId;
readonly tenantId: TenantId;
readonly kind: ChannelKind;
status: 'active' | 'paused' | 'degraded' | 'down' | 'pending_setup';
primaryVendor: string; // 'sendgrid','twilio','meta_cloud_api','fcm','apns','web_push','twilio_voice'
fallbackVendor?: string;
credentials: ChannelCredential[];
perCountrySenders: Record<ISO3166Alpha2, ChannelCredentialId>; // SMS/WhatsApp/voice
defaultSenderId?: ChannelCredentialId;
dailyBudget?: { currency: 'USD'|'PKR'|'AED'|'IRR'|'AFN'; amountMicro: bigint }; // for marketing
rateLimits: RateLimit[];
health: { healthyAt?: ISODate; lastFailureAt?: ISODate; consecutiveFailures: number };
readonly createdAt: ISODate;
readonly updatedAt: ISODate;
}

export interface ChannelCredential {
readonly id: ChannelCredentialId;
readonly channelId: ChannelId;
readonly kind: 'api_key' | 'sender_id' | 'dkim_selector' | 'whatsapp_business' | 'fcm_project' | 'apns_p8' | 'voice_caller_id';
readonly name: string;
readonly identifier: string; // public-facing identity (sender ID, domain, caller ID)
readonly secretRef?: SecretRef; // KMS-wrapped reference; never plain
readonly forCountryCode?: ISO3166Alpha2;
verifiedAt?: ISODate; // domain DKIM verified, PTA registration confirmed, etc.
registrationDocs?: Record<string, string>; // PTA filing id, Meta WABA id, DKIM record ref
status: 'active' | 'pending_verification' | 'rejected' | 'rotated';
rotatedAt?: ISODate;
rotatedBy?: UserId;
}

export interface SecretRef {
provider: 'gcp_secret_manager';
resource: string; // 'projects/.../secrets/.../versions/...'
}

Invariants:

  • Channel.kind and ChannelCredential.kind must be compatible (e.g., a dkim_selector only on email).
  • Dispatching requires Channel.status in {active, degraded}; degraded triggers fallback vendor automatically.
  • Per-country sender resolution: perCountrySenders[recipient.countryCode] || defaultSenderId || error.
  • For PK and AE recipients on sms, defaultSenderId is not sufficient — perCountrySenders.PK (PTA-registered) and perCountrySenders.AE (TRA-registered) are mandatory. Domain throws MELMASTOON.NOTIFICATION.SENDER_ID_MISSING when missing.

8. Aggregate: WebhookInbound

export interface WebhookInbound {
readonly id: WebhookInboundId;
readonly tenantId: TenantId | null; // null when vendor doesn't carry tenant context (resolved post-correlation)
readonly vendor: string; // 'sendgrid','twilio','meta_cloud_api','fcm','apns'
readonly receivedAt: ISODate;
readonly headers: Record<string, string>; // sanitized
readonly bodyUri: string; // GCS URI of raw body
readonly bodyChecksumSha256: string;
readonly signatureValid: boolean;
readonly correlatedNotificationIds: NotificationId[]; // 0..N
readonly events: VendorEvent[]; // parsed event list (a webhook can carry many)
status: 'received' | 'verified' | 'applied' | 'rejected' | 'dlq';
dlqReason?: string;
}

export interface VendorEvent {
vendorMessageId?: string;
type: 'delivered' | 'bounce' | 'complaint' | 'open' | 'click' | 'failed' | 'undelivered' | 'sent';
occurredAt: ISODate;
meta?: Record<string, JsonValue>;
}

Invariants:

  • signatureValid must be true to transition to verified.
  • Idempotency keyed on (vendor, vendorMessageId, eventType, occurredAt); duplicate webhooks fold into the same record.

9. Aggregate: DispatchBatch (marketing/dunning blasts)

export interface DispatchBatch {
readonly id: DispatchBatchId;
readonly tenantId: TenantId;
readonly templateId: TemplateId;
readonly templateVersionId: TemplateVersionId;
readonly category: NotificationCategory; // typically 'marketing' or 'dunning'
readonly initiatedBy: UserId;
readonly initiatedAt: ISODate;
readonly recipientCount: number;
readonly channelMix: Record<ChannelKind, number>;
readonly scheduledStartAt?: ISODate;
status: 'pending_approval' | 'scheduled' | 'running' | 'paused' | 'completed' | 'cancelled';
approvedBy?: UserId;
approvedAt?: ISODate;
childNotificationIds: NotificationId[]; // appended as Notifications are enqueued
readonly createdAt: ISODate;
readonly updatedAt: ISODate;
}

Invariants:

  • category in {marketing, dunning} requires pending_approval initially; transition to scheduled|running requires (approvedBy, approvedAt).
  • recipientCount * estimated_unit_cost <= channel.dailyBudget.amountMicro at activation; otherwise raises MELMASTOON.NOTIFICATION.BUDGET_EXHAUSTED.

10. Domain services (pure)

10.1 PreferenceGate

function preferenceGate(
notif: NotificationDraft,
prefs: RecipientPreferences,
suppression: SuppressionLookup,
now: ISODate
): SendDecision;

Rules in order:

  1. If suppression.has(channel, addressHash)suppress(reason='hard_bounce'|'opt_out'|'manual_block') (use the reason on the suppression row).
  2. If prefs.globalUnsubscribedAt && category != 'security|compliance|mobile_key'suppress('opt_out').
  3. If prefs.channels[category][channel] === 'off':
    • And category in regulated set → ignore (override) and continue.
    • Else → suppress('opt_out').
  4. If prefs.quietHours.enabled and nowLocal in [start, end):
    • And priority === 'critical' and prefs.quietHours.overrideForCritical → continue.
    • Else → defer(nextEndOfQuiet).
  5. Otherwise → send.

10.2 SenderResolver

function resolveSender(channel: Channel, recipient: RecipientAddress): Result<Sender, SenderError>;

Picks the per-country credential; for email returns the verified domain from; for push returns the FCM project / APNs key reference. Returns a typed error that the application service maps to MELMASTOON.NOTIFICATION.SENDER_ID_MISSING or MELMASTOON.NOTIFICATION.PROVIDER_UNAVAILABLE.

10.3 TemplateRenderer

function render(
template: TemplateVersion,
variables: Record<string, JsonValue>,
locale: Locale,
themeTokens: ThemeTokens, // from theme-config-service
channel: ChannelKind
): Result<RenderedMessage, RenderError>;
  • Resolves the locale via the fallback chain.
  • Compiles the Handlebars AST (cached at the application layer by (templateVersionId, locale)).
  • For email/mjml: MJML → HTML → inline CSS → derive plaintext fallback.
  • For sms: enforces 160-char GSM-7 segmentation budget; renders fallback plaintext if Unicode segmentation is required (>70 chars).
  • For whatsapp: produces whatsappTemplateRef payload pointing to the pre-approved Meta template; rejects free-text bodies.
  • For push_*: title + body + data; deep-link wrapped via the platform link service.
  • For voice: renders SSML with locale-appropriate TTS voice hints.
  • Locale direction (RTL/LTR) inferred by dirOf(locale); MJML dir="rtl" flag applied.

10.4 RateLimiter

function check(
scope: 'tenant_channel_day' | 'recipient_channel_day' | 'tenant_template_day',
ids: { tenantId: TenantId; channel: ChannelKind; recipientId?: RecipientId; templateId?: TemplateId },
now: ISODate
): RateLimitDecision;

Returns allow, defer(until), or suppress('rate_limit'). Backed by Redis counters; Postgres fallback when Redis is degraded.

10.5 TriggerMapResolver

interface TriggerMapEntry {
eventType: string; // e.g., 'melmastoon.reservation.confirmed.v1'
predicate?: JsonLogic; // optional payload filter
templates: Array<{
key: TemplateKey;
channels: ChannelKind[];
recipientResolver: 'primary_guest' | 'tenant_admins' | 'staff_assignee' | 'vendor_assignee' | 'iam_subject';
schedule?: { offsetSeconds: number }; // pre-arrival = -86400, post-stay = +86400
}>;
}

function resolveTriggers(eventType: string, payload: unknown, tenantOverrides: TriggerMapEntry[]): TriggerMatch[];

Trigger map is data-driven (stored in notification_trigger_map table; reloaded every 30 s). Tenants may add tenant-scoped entries that supplement the platform map (cannot remove platform entries for regulated categories).

10.6 LocalisedFormatter

Locale-aware date/time/number/currency formatting using Intl.DateTimeFormat/Intl.NumberFormat with a calendar override (fa-AF uses Persian calendar; ps-AF uses Hijri-Shamsi).


11. Domain events (published — wire shapes in EVENT_SCHEMAS)

EventTriggerPayload key fields
notification.requested.v1accept()notificationId, tenantId, recipientId, channel, category, templateKey, sourceEvent
notification.scheduled.v1schedule()notificationId, scheduledFor, reason ('quiet_hours'
notification.dispatched.v1dispatch()notificationId, vendor, vendorMessageId, sender.identifier, attemptNumber
notification.delivered.v1markDelivered()notificationId, deliveredAt, latencyMs
notification.failed.v1vendorRejectedTerminal()notificationId, reason, attempts, lastError
notification.bounced.v1markBounced()notificationId, bounceType, addressHash
notification.opened.v1markOpened()notificationId, openedAt, agentFamily
notification.clicked.v1markClicked()notificationId, linkIndex, urlHash
notification.suppressed.v1suppress()notificationId, reason
notification.opted_out.v1Recipient.globalUnsubscribe()recipientId, channel?, category?
notification.template.published.v1TemplateVersion.publish()templateId, templateVersionId, key, semver, locales[]
notification.template.archived.v1Template.archive()templateId, archivedAt, successorKey?
notification.preferences.updated.v1RecipientPreferences mutatedrecipientId, changedFields[], priorVersion, newVersion
notification.channel.health_changed.v1Channel health flippedchannelId, kind, oldStatus, newStatus, vendor, since

12. Aggregate boundaries diagram


13. Invariant-enforcement layer summary

ConcernEnforced where
Cross-tenant referencesAggregate constructors via TenantId VO
Channel ↔ recipient address kind matchingNotification.create() factory
State-machine legalityGuarded methods on NotificationAggregate and TemplateVersion
Regulated-category opt-out overrideRecipientPreferences.setChannelPreference
Sender-ID presencedispatch() requires non-null Sender argument
AI-draft HITL gateTemplateVersion.publish(approver?)
WhatsApp template approvalRenderer + dispatch() re-check
Webhook signatureWebhookInbound.markVerified()
OCC version checkrepositories on save(...)

The application layer is responsible for fetching the inputs (preferences, suppression, sender, theme); the domain layer never performs I/O.