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:
| From | To | Trigger | Guard |
|---|---|---|---|
pending | requested | accept() | preference + suppression gate passed |
pending | suppressed | suppress(reason) | preference / suppression / opt-out hit |
requested | scheduled | schedule(at) | at > now() |
requested | queued | enqueue() | scheduledFor is null |
scheduled | queued | tick() | now() >= scheduledFor |
queued | dispatching | dispatch() | rate-limit OK; sender resolved |
dispatching | dispatched | vendorAccepted(attempt) | vendor returned 2xx |
dispatching | queued | vendorRejectedRetryable(attempt) | attempts.length < 6 |
dispatching | failed | vendorRejectedTerminal(attempt) | non-retryable; or attempts.length >= 6 |
dispatched | delivered | markDelivered(at) | webhook event received |
dispatched | bounced | markBounced(reason) | webhook event received |
delivered | opened | markOpened(at) | email open pixel; channel = email only |
opened | clicked | markClicked(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
tenantId,recipientId,templateVersionId,channel,categoryare immutable post-construction.recipientAddress.kindmatcheschannel:email↔email;sms/voice↔phone_sms/voice;whatsapp↔phone_whatsapp;push_mobile/push_web↔push_token;inapp↔inapp. Construction throwsMELMASTOON.NOTIFICATION.RECIPIENT_INVALIDotherwise.attempts.length <= 6for non-inapp;inappaccepts at most 1 attempt.- Any
dispatch()requires a non-null resolvedSender; the application service injects it. Domain throwsMELMASTOON.NOTIFICATION.SENDER_ID_MISSINGif not provided. category in {security, compliance}cannot be combined withsuppress(reason='opt_out'). Construction throwsMELMASTOON.NOTIFICATION.CHANNEL_DISABLEDis masked — these categories override.priority='critical'is only accepted forsecurityandmobile_keycategories.retentionClassis derived:regulatedfortransactional|invoice|mobile_key|security|compliance|dunning;operationalforreminder|operational|marketing;auditfor any internal-only notification.- Once
statusis terminal (delivered|failed|bounced|suppressed), the aggregate refuses any state-mutating method exceptmarkOpened/markClicked(email-only post-delivery). aiProvenanceis required whentemplateVersionSemverresolves to aTemplateVersion.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
(tenantId, key)is unique. A tenant-overridden template shadows the platform template with the same key.(templateId, semver)is unique within the template.- At least one
LocalizedBodymust be present in everyTemplateVersion; 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). - Variable names referenced in
body/subjectTemplatemust be declared inTemplate.variables(validated atpublish()); unknowns raiseMELMASTOON.NOTIFICATION.TEMPLATE_VARIABLES_UNDECLARED. - Once
TemplateVersion.status = 'active',locales,subjectTemplate, andwhatsappTemplateRefsare immutable. Mutations bumpsemver. TemplateVersion.source = 'ai_drafted'cannot bepublished()without(approvedBy, approvedAt)— HITL gate. The application use case raisesMELMASTOON.AI.HITL_REQUIREDif absent.- Archiving requires a
successorKeyif the template is referenced by an active trigger-map entry. - WhatsApp Business templates with
whatsappTemplateRefs[locale].status != 'approved'cannot be dispatched. Dispatch raisesMELMASTOON.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
channels.security.{email,sms,inapp}cannot be'off'(regulatory override).channels.compliance.{email,inapp}cannot be'off'.channels.mobile_key.{whatsapp,sms}cannot both be'off'while the recipient has any active stay (enforced by application service that consultsreservation-serviceprojections).localemust be a member ofLOCALE_FALLBACKkeys.timezonemust be a valid IANA name.quietHoursstart/end must parse asHH:mm.- If
minorFlag = true,guardianRecipientIds.length >= 1. globalUnsubscribedAtdoes 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 toexpiresAt = +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.kindandChannelCredential.kindmust be compatible (e.g., adkim_selectoronly onemail).- Dispatching requires
Channel.status in {active, degraded};degradedtriggers fallback vendor automatically. - Per-country sender resolution:
perCountrySenders[recipient.countryCode] || defaultSenderId || error. - For PK and AE recipients on
sms,defaultSenderIdis not sufficient —perCountrySenders.PK(PTA-registered) andperCountrySenders.AE(TRA-registered) are mandatory. Domain throwsMELMASTOON.NOTIFICATION.SENDER_ID_MISSINGwhen 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:
signatureValidmust betrueto transition toverified.- 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}requirespending_approvalinitially; transition toscheduled|runningrequires(approvedBy, approvedAt).recipientCount * estimated_unit_cost <= channel.dailyBudget.amountMicroat activation; otherwise raisesMELMASTOON.NOTIFICATION.BUDGET_EXHAUSTED.
10. Domain services (pure)
10.1 PreferenceGate
function preferenceGate(
notif: NotificationDraft,
prefs: RecipientPreferences,
suppression: SuppressionLookup,
now: ISODate
): SendDecision;
Rules in order:
- If
suppression.has(channel, addressHash)→suppress(reason='hard_bounce'|'opt_out'|'manual_block')(use the reason on the suppression row). - If
prefs.globalUnsubscribedAt && category != 'security|compliance|mobile_key'→suppress('opt_out'). - If
prefs.channels[category][channel] === 'off':- And category in regulated set → ignore (override) and continue.
- Else →
suppress('opt_out').
- If
prefs.quietHours.enabledandnowLocal in [start, end):- And
priority === 'critical'andprefs.quietHours.overrideForCritical→ continue. - Else →
defer(nextEndOfQuiet).
- And
- 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: produceswhatsappTemplateRefpayload 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); MJMLdir="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)
| Event | Trigger | Payload key fields |
|---|---|---|
notification.requested.v1 | accept() | notificationId, tenantId, recipientId, channel, category, templateKey, sourceEvent |
notification.scheduled.v1 | schedule() | notificationId, scheduledFor, reason ('quiet_hours' |
notification.dispatched.v1 | dispatch() | notificationId, vendor, vendorMessageId, sender.identifier, attemptNumber |
notification.delivered.v1 | markDelivered() | notificationId, deliveredAt, latencyMs |
notification.failed.v1 | vendorRejectedTerminal() | notificationId, reason, attempts, lastError |
notification.bounced.v1 | markBounced() | notificationId, bounceType, addressHash |
notification.opened.v1 | markOpened() | notificationId, openedAt, agentFamily |
notification.clicked.v1 | markClicked() | notificationId, linkIndex, urlHash |
notification.suppressed.v1 | suppress() | notificationId, reason |
notification.opted_out.v1 | Recipient.globalUnsubscribe() | recipientId, channel?, category? |
notification.template.published.v1 | TemplateVersion.publish() | templateId, templateVersionId, key, semver, locales[] |
notification.template.archived.v1 | Template.archive() | templateId, archivedAt, successorKey? |
notification.preferences.updated.v1 | RecipientPreferences mutated | recipientId, changedFields[], priorVersion, newVersion |
notification.channel.health_changed.v1 | Channel health flipped | channelId, kind, oldStatus, newStatus, vendor, since |
12. Aggregate boundaries diagram
13. Invariant-enforcement layer summary
| Concern | Enforced where |
|---|---|
| Cross-tenant references | Aggregate constructors via TenantId VO |
| Channel ↔ recipient address kind matching | Notification.create() factory |
| State-machine legality | Guarded methods on NotificationAggregate and TemplateVersion |
| Regulated-category opt-out override | RecipientPreferences.setChannelPreference |
| Sender-ID presence | dispatch() requires non-null Sender argument |
| AI-draft HITL gate | TemplateVersion.publish(approver?) |
| WhatsApp template approval | Renderer + dispatch() re-check |
| Webhook signature | WebhookInbound.markVerified() |
| OCC version check | repositories on save(...) |
The application layer is responsible for fetching the inputs (preferences, suppression, sender, theme); the domain layer never performs I/O.