2026 03 19 Business Communications System Design
Documentation for Plans
Business Communications System — Space Builder → Customer Messaging
Session: S241 Date: 2026-03-19 Status: Design Proposed Owner: backend-api (primary), @libs-api/messaging-core (shared delivery), backend-vitto-api (optional AI enrichment) Predecessor: S241 Platform Notification System (companion spec), S210.4 (ProactiveDispatcher), S211 (Rich Messaging) Related: Singularity flow engine, Ecommerce module, PlatformUsersModule
Executive Summary
The business communications system enables GoGrab space builders to send messages to their own customers (L2 platform users) across WhatsApp, email, SMS, and other channels. This is fundamentally different from the platform notification system (Spec 1) — it serves a different audience, different compliance requirements, and different ownership model.
Why this lives in backend-api, not backend-vitto-api:
- L2 platform users, spaces, universes, ecommerce orders, and Singularity flows all live in
backend-api - Template CRUD, customer consent, and campaign management are product data — not AI orchestration
- Routing every order confirmation through Vitto's agent loop adds latency and a failure dependency for zero value
@libs-api/messaging-core(TwilioService, EmailService, VittoIdentityService) is already imported inbackend-api
What this provides:
- Template registry — per-space message templates with variable substitution, editable by space builders
- Multi-channel delivery — WhatsApp (Twilio + Cloud API), email (SendGrid), SMS, with per-space BYOT credentials
- Customer consent management — opt-in/opt-out tracking for GDPR/TCPA/WhatsApp Business Policy compliance
- Broadcast & targeting — send to all customers, filtered by universe/role/custom segments
- Delivery log & analytics — audit trail, delivery status, open/click tracking
- Singularity integration — flow engine notification blocks trigger real deliveries
- Optional AI enrichment — Vitto can personalize message content when requested
Design principles:
- Simple first: start with the infrastructure that makes sending a single notification to a single customer possible, then layer on broadcast and campaigns
- Comply by default: no message can be sent to a customer who hasn't consented
- Use existing primitives:
@libs-api/messaging-corefor delivery,VittoIdentityServicefor BYOT credentials,ScheduledMessageQueueService(via bridge) for deferred delivery
Table of Contents
- Architecture Overview
- Module Structure
- Template Registry
- Customer Contact & Consent
- Delivery Service
- Broadcast & Targeting
- Delivery Log
- Singularity Flow Integration
- Optional AI Enrichment
- Schemas
- API Endpoints
- Key Files to Create/Modify
- WhatsApp Compliance
- Risks & Mitigations
1. Architecture Overview
┌────────────────────────────────────────────────────────────────────┐
│ backend-api (:3000) │
│ │
│ ┌─────────────────────────┐ ┌───────────────────────────────┐ │
│ │ Message Sources │ │ CommunicationsModule (NEW) │ │
│ │ │ │ │ │
│ │ • Admin UI sends │──▶│ TemplateService │ │
│ │ • Ecommerce events │ │ DeliveryService │ │
│ │ • Singularity flows │ │ BroadcastService │ │
│ │ • API calls │ │ ConsentService │ │
│ │ • Scheduled campaigns │ │ DeliveryLogService │ │
│ └─────────────────────────┘ └───────────┬───────────────────┘ │
│ │ │
│ ┌─────────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ConsentService DeliveryService DeliveryLog │
│ (check before (send via (persist │
│ every send) messaging-core) results) │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────┐ │
│ │ @libs-api/messaging-core │ │
│ │ │ │
│ │ TwilioService EmailService │ │
│ │ VittoIdentityService (BYOT) │ │
│ │ TwilioContentApiService │ │
│ │ WhatsAppCloudApiService │ │
│ └──────────────────────────────────┘ │
│ │
│ Optional: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ NotificationBridgeClientService (from Spec 1) │ │
│ │ → POST /vitto-api/engine/internal/notifications/... │ │
│ │ Used when AI enrichment is requested │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
Why NOT route through Vitto
| Factor | Direct delivery (chosen) | Via Vitto bridge |
|---|---|---|
| Latency | ~200ms (direct Twilio call) | ~500ms+ (HTTP hop to vitto + dispatch) |
| Failure modes | 1 (Twilio) | 2 (vitto-api down OR Twilio) |
| Safety guards | Consent-based (GDPR/TCPA) | Dossier-based (quiet hours, frequency cap) |
| Credentials | Same VittoIdentityService via messaging-core |
Same |
| Template handling | Local — no serialization overhead | Must serialize custom HTML across HTTP |
| Throughput | Direct batching to Twilio | Bottlenecked by vitto's single dispatch loop |
Vitto's safety guards (quiet hours, frequency cap, dedup) are designed for platform → developer notifications. Business communications have different rules: customers opt in/out per channel, and a business may legitimately send 50 messages in a day during a sale. Applying Vitto's dailyAlertCap: 5 to customer messages would break the feature.
2. Module Structure
APIs/backend-api/src/app/communications/
communications.module.ts ← NestJS module
communications.controller.ts ← REST API for space builders
communications-admin.controller.ts ← Platform admin endpoints
services/
template.service.ts ← Template CRUD + variable rendering
delivery.service.ts ← Single-recipient delivery with consent check
broadcast.service.ts ← Multi-recipient fan-out with batching
consent.service.ts ← Opt-in/opt-out management
delivery-log.service.ts ← Audit trail + analytics
rate-limiter.service.ts ← Per-space, per-channel rate limiting
schemas/
comm-template.schema.ts ← Message template documents
delivery-log.schema.ts ← Delivery audit log
customer-consent.schema.ts ← Per-customer consent records
campaign.schema.ts ← Campaign/broadcast records (future phase)
Module registration
// communications.module.ts
@Module({
imports: [
MongooseModule.forFeature([
{ name: CommTemplateDoc.name, schema: CommTemplateSchema },
{ name: DeliveryLogDoc.name, schema: DeliveryLogSchema },
{ name: CustomerConsentDoc.name, schema: CustomerConsentSchema },
]),
// MessagingCoreModule is @Global() — already available, no import needed
],
controllers: [CommunicationsController, CommunicationsAdminController],
providers: [
TemplateService,
DeliveryService,
BroadcastService,
ConsentService,
DeliveryLogService,
RateLimiterService,
],
exports: [DeliveryService, ConsentService], // For SingularityModule to inject
})
export class CommunicationsModule {}
3. Template Registry
Problem
No per-space message templates exist. Builders can't create reusable, branded messages.
Design
Templates are per-space documents with a channel target, a body with {{variable}} substitution, and optional rich content.
interface CommTemplate {
spaceId: string; // Owning space
name: string; // Human-readable name ("Order Confirmation", "Welcome Email")
slug: string; // URL-safe key ("order-confirmation"), unique per space
channel: 'email' | 'whatsapp' | 'sms' | 'slack' | 'multi';
status: 'draft' | 'active' | 'archived';
// Content variants by channel
content: {
// Plain text body with {{variable}} placeholders
text?: string;
// Email-specific
email?: {
subject: string; // "Your order {{orderId}} has been confirmed"
html: string; // Full HTML email body with {{variables}}
sendgridTemplateId?: string; // If using SendGrid dynamic templates instead
};
// WhatsApp-specific
whatsapp?: {
text: string; // WhatsApp message body with {{variables}}
contentSid?: string; // Twilio Content API template SID (pre-approved)
headerImageUrl?: string;
quickReplies?: string[]; // Up to 3 quick reply button labels
callToActionUrl?: string;
};
// SMS-specific
sms?: {
text: string; // SMS body with {{variables}} (160 char awareness)
};
};
// Variable definitions (for UI form generation and validation)
variables: Array<{
name: string; // "customerName"
type: 'string' | 'number' | 'date' | 'currency' | 'url';
required: boolean;
defaultValue?: string;
description?: string; // "The customer's full name"
}>;
// Metadata
createdBy: string; // userId of space builder
lastEditedBy?: string;
usageCount: number; // Incremented on each send
}
Variable rendering
// template.service.ts
renderTemplate(
template: CommTemplate,
channel: string,
variables: Record<string, string | number>,
): { text?: string; html?: string; subject?: string } {
const content = template.content;
// Validate required variables
for (const v of template.variables.filter(v => v.required)) {
if (variables[v.name] === undefined && !v.defaultValue) {
throw new BadRequestException(`Missing required variable: ${v.name}`);
}
}
// Merge defaults
const merged = { ...Object.fromEntries(
template.variables.map(v => [v.name, v.defaultValue ?? ''])
), ...variables };
// Substitute {{var}} in all string fields
const substitute = (str: string) =>
str.replace(/\{\{(\w+)\}\}/g, (_, key) => String(merged[key] ?? ''));
const result: any = {};
if (channel === 'email' && content.email) {
result.subject = substitute(content.email.subject);
result.html = substitute(content.email.html);
result.sendgridTemplateId = content.email.sendgridTemplateId;
result.sendgridData = content.email.sendgridTemplateId ? merged : undefined;
} else if (channel === 'whatsapp' && content.whatsapp) {
result.text = substitute(content.whatsapp.text);
result.contentSid = content.whatsapp.contentSid;
result.contentVariables = content.whatsapp.contentSid ? merged : undefined;
result.quickReplies = content.whatsapp.quickReplies;
} else if (channel === 'sms' && content.sms) {
result.text = substitute(content.sms.text);
} else if (content.text) {
result.text = substitute(content.text);
}
return result;
}
Template security
- HTML email sanitization: When a builder saves an email template with HTML, sanitize on write with a server-side sanitizer (e.g.,
sanitize-htmlorDOMPurifyviajsdom). Whitelist safe tags:<p>,<h1>-<h6>,<a>,<img>,<table>,<tr>,<td>,<div>,<span>,<br>,<strong>,<em>,<ul>,<ol>,<li>. Strip<script>,<iframe>,<object>,<embed>,onclick,onerror, etc. - Variable injection:
{{variable}}values are plain text substituted into HTML. Escape HTML entities in variable values before substitution to prevent XSS if a customer name contains<script>. - Rate limiting: Template creation and updates are rate-limited per space (max 100 templates per space, max 10 updates/minute).
4. Customer Contact & Consent
Problem
L2 platform users (platform_users collection) have email but no phone field. No consent tracking exists. WhatsApp Business Policy and GDPR require explicit opt-in before sending business-initiated messages.
Schema additions to PlatformUser
// Add to libs/libs-api/backend-core/src/lib/schemas/platform-user.schema.ts
/** S241: Contact information for communications */
@Prop()
phone?: string;
/** S241: Phone country code (e.g., '+1', '+34') */
@Prop()
phoneCountryCode?: string;
CustomerConsentDoc (NEW)
@Schema({ timestamps: true, collection: 'customer-consents' })
export class CustomerConsentDoc extends Document {
/** The L2 platform user who consented */
@Prop({ required: true, index: true })
platformUserId: string;
@Prop({ required: true, index: true })
spaceId: string;
@Prop({ index: true })
universeId?: string;
/** Channel consented to */
@Prop({ required: true, type: String })
channel: 'email' | 'whatsapp' | 'sms' | 'slack';
/** The identifier on that channel (email address, phone number, etc.) */
@Prop({ required: true })
channelId: string;
/** Consent state */
@Prop({ required: true, default: true })
consented: boolean;
/** How consent was obtained */
@Prop({ required: true, type: String })
consentSource: 'explicit_optin' | 'registration' | 'purchase' | 'api' | 'import';
/** When consent was given */
@Prop({ type: Date, default: () => new Date() })
consentedAt: Date;
/** When consent was revoked (if applicable) */
@Prop()
revokedAt?: Date;
/** Revocation method */
@Prop()
revokedVia?: 'unsubscribe_link' | 'api' | 'admin' | 'whatsapp_stop';
/** IP address at time of consent (for audit) */
@Prop()
consentIp?: string;
}
export const CustomerConsentSchema = SchemaFactory.createForClass(CustomerConsentDoc);
CustomerConsentSchema.index({ platformUserId: 1, spaceId: 1, channel: 1 }, { unique: true });
CustomerConsentSchema.index({ spaceId: 1, channel: 1, consented: 1 });
ConsentService
@Injectable()
export class ConsentService {
/** Check if a customer has active consent for a channel. MUST be called before every send. */
async hasConsent(platformUserId: string, spaceId: string, channel: string): Promise<boolean> {
const record = await this.consentModel.findOne({
platformUserId, spaceId, channel, consented: true,
}).lean();
return !!record;
}
/** Record consent (idempotent — upserts) */
async grantConsent(params: {
platformUserId: string; spaceId: string; universeId?: string;
channel: string; channelId: string;
source: CustomerConsentDoc['consentSource']; ip?: string;
}): Promise<void> { ... }
/** Revoke consent */
async revokeConsent(
platformUserId: string, spaceId: string, channel: string,
revokedVia: CustomerConsentDoc['revokedVia'],
): Promise<void> { ... }
/** Get all consented customers for a space+channel (for broadcast targeting) */
async getConsentedCustomers(
spaceId: string, channel: string, universeId?: string,
): Promise<Array<{ platformUserId: string; channelId: string }>> { ... }
}
Automatic consent on registration
When a platform user registers via PlatformUsersService.create(), call ConsentService.grantConsent() with source: 'registration' for the email channel. This is standard practice — the user signed up for the service.
WhatsApp consent
WhatsApp consent requires the user to send a message first (opt-in). When a platform user sends a WhatsApp message to the space builder's business number, the inbound webhook in backend-vitto-api calls back to backend-api via an internal endpoint to record consent:
POST /api/internal/communications/consent
{ platformUserId, spaceId, channel: 'whatsapp', channelId: '+phone', source: 'explicit_optin' }
5. Delivery Service
Core send method
@Injectable()
export class DeliveryService {
constructor(
private readonly consentService: ConsentService,
private readonly templateService: TemplateService,
private readonly deliveryLogService: DeliveryLogService,
private readonly rateLimiter: RateLimiterService,
private readonly twilioService: TwilioService,
private readonly emailService: EmailService,
private readonly identityService: VittoIdentityService,
private readonly contentApiService: TwilioContentApiService,
) {}
async send(params: {
spaceId: string;
recipientId: string; // platformUserId
channel: string;
channelId: string; // email address, phone number, etc.
templateSlug?: string; // Use a template
templateVariables?: Record<string, string | number>;
customContent?: { // OR send custom content directly
text?: string;
html?: string;
subject?: string;
whatsappContentSid?: string;
whatsappContentVariables?: Record<string, string>;
};
metadata?: Record<string, unknown>;
source?: string; // 'api', 'flow', 'admin', 'ecommerce'
}): Promise<DeliverySendResult> {
// 1. Rate limit check (per space, per channel)
const allowed = await this.rateLimiter.check(params.spaceId, params.channel);
if (!allowed) {
return this.logAndReturn(params, 'rate_limited');
}
// 2. Consent check (MANDATORY — no bypass)
const consented = await this.consentService.hasConsent(
params.recipientId, params.spaceId, params.channel,
);
if (!consented) {
return this.logAndReturn(params, 'no_consent');
}
// 3. Resolve content
let content: ResolvedContent;
if (params.templateSlug) {
const template = await this.templateService.getBySlug(params.spaceId, params.templateSlug);
if (!template) throw new NotFoundException(`Template not found: ${params.templateSlug}`);
content = this.templateService.renderTemplate(template, params.channel, params.templateVariables || {});
} else if (params.customContent) {
content = params.customContent;
} else {
throw new BadRequestException('Either templateSlug or customContent is required');
}
// 4. Resolve BYOT credentials
const identity = await this.identityService.resolveCredentials(params.spaceId);
// 5. Deliver
try {
switch (params.channel) {
case 'email':
await this.deliverEmail(params.channelId, content, identity, params.spaceId);
break;
case 'whatsapp':
await this.deliverWhatsApp(params.channelId, content, identity, params.spaceId);
break;
case 'sms':
await this.deliverSms(params.channelId, content, identity);
break;
}
return this.logAndReturn(params, 'delivered', content);
} catch (error) {
return this.logAndReturn(params, 'failed', content, (error as Error).message);
}
}
private async deliverEmail(
to: string, content: ResolvedContent,
identity: ResolvedIdentity, spaceId: string,
): Promise<void> {
if (content.sendgridTemplateId) {
// SendGrid dynamic template
await this.emailService.sendWithTemplate({
to,
templateId: content.sendgridTemplateId,
dynamicTemplateData: content.sendgridData,
from: identity.email?.address,
fromName: identity.email?.fromName,
apiKey: identity.email?.sendgridApiKey,
});
} else if (content.html) {
// Custom HTML
await this.emailService.sendRaw({
to,
subject: content.subject || 'Notification',
html: content.html,
from: identity.email?.address,
fromName: identity.email?.fromName,
apiKey: identity.email?.sendgridApiKey,
});
} else {
// Plain text fallback
await this.emailService.sendRaw({
to,
subject: content.subject || 'Notification',
html: `<p>${content.text}</p>`,
from: identity.email?.address,
fromName: identity.email?.fromName,
apiKey: identity.email?.sendgridApiKey,
});
}
}
private async deliverWhatsApp(
to: string, content: ResolvedContent,
identity: ResolvedIdentity, spaceId: string,
): Promise<void> {
// WhatsApp business-initiated messages ALWAYS require a template
// (24-hour window rule — the customer hasn't messaged this business number recently)
if (content.contentSid) {
await this.twilioService.sendWhatsAppTemplate(to, content.contentSid, content.contentVariables, {
accountSid: identity.whatsapp?.twilioAccountSid,
authToken: identity.whatsapp?.twilioAuthToken,
fromNumber: identity.whatsapp?.twilioWhatsappNumber,
});
} else {
// Freeform text — will FAIL if outside 24h window
// Log warning but attempt anyway (Twilio returns error 63016 if outside window)
await this.twilioService.sendWhatsAppMessage(to, content.text, {
accountSid: identity.whatsapp?.twilioAccountSid,
authToken: identity.whatsapp?.twilioAuthToken,
fromNumber: identity.whatsapp?.twilioWhatsappNumber,
});
}
}
}
6. Broadcast & Targeting
BroadcastService
interface BroadcastParams {
spaceId: string;
channel: string;
templateSlug: string;
templateVariables?: Record<string, string | number>; // Static variables (same for all)
dynamicVariables?: (recipient: PlatformUser) => Record<string, string | number>; // Per-recipient
/** Audience targeting */
target:
| { type: 'universe'; universeId: string } // All customers in a universe
| { type: 'space' } // All customers across all universes
| { type: 'role'; universeId: string; role: string } // Customers with specific role
| { type: 'segment'; platformUserIds: string[] } // Explicit list
| { type: 'filter'; universeId: string; filter: Record<string, unknown> }; // Custom MongoDB filter on metadata
/** Delivery control */
concurrency?: number; // Default: 30
source?: string;
}
interface BroadcastResult {
broadcastId: string;
totalRecipients: number;
consented: number;
delivered: number;
failed: number;
noConsent: number;
rateLimited: number;
}
Resolution and execution
@Injectable()
export class BroadcastService {
async broadcast(params: BroadcastParams): Promise<BroadcastResult> {
const broadcastId = new Types.ObjectId().toString();
// 1. Resolve audience
const recipients = await this.resolveAudience(params.spaceId, params.target);
// 2. Get consented recipients for this channel
const consentedSet = new Set(
(await this.consentService.getConsentedCustomers(params.spaceId, params.channel))
.map(c => c.platformUserId)
);
const eligible = recipients.filter(r => consentedSet.has(r._id.toString()));
const noConsent = recipients.length - eligible.length;
// 3. Resolve contact info per recipient
const deliveryList = eligible
.map(r => ({
recipientId: r._id.toString(),
channelId: this.resolveChannelId(r, params.channel),
variables: params.dynamicVariables ? params.dynamicVariables(r) : params.templateVariables,
}))
.filter(d => d.channelId); // Skip recipients without channel contact info
// 4. Batch delivery with throttling
const limit = pLimit(params.concurrency || 30);
const results = await Promise.allSettled(
deliveryList.map(d => limit(() =>
this.deliveryService.send({
spaceId: params.spaceId,
recipientId: d.recipientId,
channel: params.channel,
channelId: d.channelId,
templateSlug: params.templateSlug,
templateVariables: d.variables as Record<string, string | number>,
metadata: { broadcastId },
source: params.source || 'broadcast',
})
))
);
// 5. Aggregate results
let delivered = 0, failed = 0, rateLimited = 0;
for (const r of results) {
if (r.status === 'fulfilled') {
if (r.value.status === 'delivered') delivered++;
else if (r.value.status === 'rate_limited') rateLimited++;
else failed++;
} else {
failed++;
}
}
return { broadcastId, totalRecipients: recipients.length, consented: eligible.length, delivered, failed, noConsent, rateLimited };
}
private resolveChannelId(user: PlatformUser, channel: string): string | null {
switch (channel) {
case 'email': return user.email;
case 'whatsapp':
case 'sms':
return user.phone ? `${user.phoneCountryCode || ''}${user.phone}` : null;
default: return null;
}
}
}
Rate limiting
@Injectable()
export class RateLimiterService {
// Redis-backed sliding window per space per channel
// Default limits (configurable per space via SpaceSettings):
// email: 1000/hour
// whatsapp: 500/hour (Twilio's rate + cost awareness)
// sms: 200/hour
async check(spaceId: string, channel: string): Promise<boolean> { ... }
}
Why Redis, not in-memory: Unlike Spec 1's ProactiveDispatcherService (low volume, single-user), business broadcasts can hit thousands of recipients. In-memory counters would be lost on restart mid-broadcast. Redis sliding window is the correct primitive here. backend-api already imports RedisModule from @libs-api/backend-core.
7. Delivery Log
@Schema({ timestamps: true, collection: 'comm-delivery-log' })
export class DeliveryLogDoc extends Document {
@Prop({ required: true, index: true })
spaceId: string;
@Prop({ index: true })
broadcastId?: string;
@Prop({ required: true, index: true })
recipientId: string; // platformUserId
@Prop({ required: true })
channel: string;
@Prop({ required: true })
channelId: string; // email/phone
@Prop()
templateSlug?: string;
@Prop({ required: true, type: String })
status: 'delivered' | 'failed' | 'no_consent' | 'rate_limited' | 'bounced';
@Prop()
errorMessage?: string;
/** Twilio/SendGrid message ID for status tracking */
@Prop()
providerMessageId?: string;
/** Twilio delivery status callback updates */
@Prop({ type: String })
deliveryStatus?: 'queued' | 'sent' | 'delivered' | 'read' | 'failed' | 'undelivered';
@Prop()
source?: string; // 'api', 'broadcast', 'flow', 'ecommerce'
@Prop({ type: Object })
metadata?: Record<string, unknown>;
// TTL: 180 days
@Prop({ type: Date, default: () => new Date(), index: { expires: '180d' } })
expiresAt: Date;
}
export const DeliveryLogSchema = SchemaFactory.createForClass(DeliveryLogDoc);
DeliveryLogSchema.index({ spaceId: 1, createdAt: -1 });
DeliveryLogSchema.index({ spaceId: 1, recipientId: 1 });
DeliveryLogSchema.index({ broadcastId: 1 });
DeliveryLogSchema.index({ providerMessageId: 1 }, { sparse: true });
Delivery status webhook integration
Twilio status callbacks (POST /vitto-api/engine/integrations/whatsapp/status) already update message-statuses collection via MessageStatusService. For business communications, add a secondary update path:
When MessageStatusService.processStatusCallback() receives a status update for a messageSid, check if a DeliveryLogDoc exists with that providerMessageId and update its deliveryStatus. This can be done via an EventEmitter2 event that CommunicationsModule subscribes to, or a shared MongoDB trigger.
Pragmatic approach: Add an optional HTTP callback from vitto-api to backend-api's internal endpoint:
POST /api/internal/communications/delivery-status
{ providerMessageId, status }
8. Singularity Flow Integration
Problem
LogicBlockExecutorService.executeNotificationAction() (line 583 of logic-block-executor.service.ts) is a stub that logs but doesn't deliver.
Solution
Inject DeliveryService from CommunicationsModule into LogicBlockExecutorService and replace the stub:
private async executeNotificationAction(
block: LogicBlock,
context: ExecutionContext,
): Promise<BlockExecutionResult> {
const channel = block.properties?.channel || 'email';
const templateSlug = block.properties?.templateSlug;
const recipientId = this.variableResolver.resolveTemplate(
block.properties?.recipientId || '',
context.variableContext,
);
// Build variables from block properties + flow context
const variables: Record<string, string> = {};
for (const [key, value] of Object.entries(block.properties?.variables || {})) {
variables[key] = this.variableResolver.resolveTemplate(String(value), context.variableContext);
}
// Resolve channel ID from the recipient
const recipient = await this.platformUserModel.findById(recipientId).lean();
if (!recipient) {
return { success: false, blockId: block.id, blockType: block.type, outputs: {}, error: 'Recipient not found', duration: 0 };
}
const channelId = channel === 'email' ? recipient.email : recipient.phone;
const result = await this.deliveryService.send({
spaceId: context.spaceId,
recipientId,
channel,
channelId,
templateSlug,
templateVariables: variables,
source: 'flow',
metadata: { flowId: context.flowId, blockId: block.id },
});
return {
success: result.status === 'delivered',
blockId: block.id,
blockType: block.type,
outputs: { status: result.status, channel, recipientId },
duration: 0,
};
}
Ecommerce integration
Similarly, OrdersService.updateStatus() can emit an event that triggers a notification via DeliveryService:
// In OrdersService.updateStatus(), after timeline append:
this.eventEmitter.emit('order.status_changed', {
spaceId, universeId, orderId, status: newStatus, platformUserId: order.userId,
});
// In CommunicationsModule, a listener:
@OnEvent('order.status_changed')
async handleOrderStatusChange(event: OrderStatusChangedEvent) {
const templateSlug = `order-${event.status}`; // 'order-confirmed', 'order-shipped', etc.
const template = await this.templateService.getBySlug(event.spaceId, templateSlug);
if (!template) return; // Space hasn't configured this template — skip silently
const user = await this.platformUserModel.findById(event.platformUserId).lean();
if (!user) return;
await this.deliveryService.send({
spaceId: event.spaceId,
recipientId: event.platformUserId,
channel: 'email', // Or resolve from customer preferences
channelId: user.email,
templateSlug,
templateVariables: { orderId: event.orderId, status: event.status },
source: 'ecommerce',
});
}
9. Optional AI Enrichment
For space builders who want Vitto to personalize message content, the DeliveryService can optionally call Vitto before sending:
// Optional — only when builder requests AI enrichment
async enrichWithAI(params: {
spaceId: string;
templateContent: string; // The rendered template text
recipientContext?: string; // Any context about the recipient
tone?: string; // "friendly", "formal", "urgent"
}): Promise<string> {
// Call Vitto via NotificationBridgeClientService (from Spec 1)
// Or call the LLM directly via a lightweight inference endpoint
const response = await this.notificationBridge.enrich({
text: params.templateContent,
context: params.recipientContext,
tone: params.tone,
spaceId: params.spaceId,
});
return response.enrichedText;
}
This is Phase 2 — not part of the initial implementation. The infrastructure supports it via the bridge, but don't build it until a space builder actually needs it.
10. Schemas
CommTemplateDoc
@Schema({ timestamps: true, collection: 'comm-templates' })
export class CommTemplateDoc extends Document {
@Prop({ required: true, index: true })
spaceId: string;
@Prop({ required: true })
name: string;
@Prop({ required: true })
slug: string;
@Prop({ required: true, type: String })
channel: 'email' | 'whatsapp' | 'sms' | 'slack' | 'multi';
@Prop({ type: String, default: 'draft' })
status: 'draft' | 'active' | 'archived';
@Prop({ type: Object, required: true })
content: {
text?: string;
email?: { subject: string; html: string; sendgridTemplateId?: string };
whatsapp?: { text: string; contentSid?: string; headerImageUrl?: string; quickReplies?: string[]; callToActionUrl?: string };
sms?: { text: string };
};
@Prop({ type: [{ name: String, type: { type: String }, required: Boolean, defaultValue: String, description: String }] })
variables: Array<{
name: string; type: string; required: boolean;
defaultValue?: string; description?: string;
}>;
@Prop({ required: true })
createdBy: string;
@Prop()
lastEditedBy?: string;
@Prop({ default: 0 })
usageCount: number;
}
export const CommTemplateSchema = SchemaFactory.createForClass(CommTemplateDoc);
CommTemplateSchema.index({ spaceId: 1, slug: 1 }, { unique: true });
CommTemplateSchema.index({ spaceId: 1, status: 1 });
CustomerConsentDoc
See Section 4 above.
DeliveryLogDoc
See Section 7 above.
11. API Endpoints
Space builder endpoints (scoped by JWT spaceId)
| Method | Route | Purpose |
|---|---|---|
| Templates | ||
GET |
/api/communications/templates |
List templates for space |
GET |
/api/communications/templates/:slug |
Get template by slug |
POST |
/api/communications/templates |
Create template |
PUT |
/api/communications/templates/:id |
Update template |
DELETE |
/api/communications/templates/:id |
Archive template |
POST |
/api/communications/templates/:slug/preview |
Render template with sample variables |
POST |
/api/communications/templates/:slug/test-send |
Send to a test recipient (space builder's own email/phone) |
| Sending | ||
POST |
/api/communications/send |
Send to single recipient |
POST |
/api/communications/broadcast |
Broadcast to audience |
| Consent | ||
GET |
/api/communications/consent?universeId=X |
List consented customers |
POST |
/api/communications/consent |
Grant consent (admin action) |
DELETE |
/api/communications/consent/:platformUserId/:channel |
Revoke consent (admin action) |
| Logs | ||
GET |
/api/communications/log |
Delivery log for space (paginated) |
GET |
/api/communications/log/broadcast/:broadcastId |
Results for a specific broadcast |
GET |
/api/communications/stats |
Delivery statistics (delivered/failed/bounced counts) |
Customer-facing endpoints (L2 auth)
| Method | Route | Purpose |
|---|---|---|
GET |
/api/communications/unsubscribe/:token |
One-click email unsubscribe (GET for email client compatibility) |
POST |
/api/communications/preferences |
Customer updates their channel preferences |
Internal (service-to-service)
| Method | Route | Purpose |
|---|---|---|
POST |
/api/internal/communications/consent |
Grant consent from vitto-api (WhatsApp opt-in) |
POST |
/api/internal/communications/delivery-status |
Delivery status update from vitto-api |
12. Key Files to Create/Modify
New files
| File | Purpose |
|---|---|
APIs/backend-api/src/app/communications/communications.module.ts |
Module definition |
APIs/backend-api/src/app/communications/communications.controller.ts |
Space builder REST API |
APIs/backend-api/src/app/communications/communications-admin.controller.ts |
Platform admin endpoints |
APIs/backend-api/src/app/communications/services/template.service.ts |
Template CRUD + rendering |
APIs/backend-api/src/app/communications/services/delivery.service.ts |
Single-recipient delivery |
APIs/backend-api/src/app/communications/services/broadcast.service.ts |
Multi-recipient fan-out |
APIs/backend-api/src/app/communications/services/consent.service.ts |
Consent management |
APIs/backend-api/src/app/communications/services/delivery-log.service.ts |
Audit trail |
APIs/backend-api/src/app/communications/services/rate-limiter.service.ts |
Redis rate limiting |
APIs/backend-api/src/app/communications/schemas/comm-template.schema.ts |
Template schema |
APIs/backend-api/src/app/communications/schemas/delivery-log.schema.ts |
Delivery log schema |
APIs/backend-api/src/app/communications/schemas/customer-consent.schema.ts |
Consent schema |
Modified files
| File | Change |
|---|---|
APIs/backend-api/src/app/app.module.ts |
Import CommunicationsModule |
libs/libs-api/backend-core/src/lib/schemas/platform-user.schema.ts |
Add phone, phoneCountryCode fields |
libs/libs-api/messaging-core/src/lib/services/email.service.ts |
Add sendRaw(), sendWithTemplate() methods |
APIs/backend-api/src/app/singularity/services/logic-block-executor.service.ts |
Replace stub executeNotificationAction() with real delivery |
APIs/backend-api/src/app/singularity/singularity.module.ts |
Import CommunicationsModule |
APIs/backend-api/src/app/ecommerce/orders/orders.service.ts |
Emit order.status_changed event |
APIs/backend-api/src/app/ecommerce/ecommerce.module.ts |
Import CommunicationsModule |
13. WhatsApp Compliance
Business-initiated messages require pre-approved templates
WhatsApp Business Policy mandates that messages sent outside the 24-hour conversation window use pre-approved message templates. This applies to ALL System B messages (business → customer) because the customer hasn't been chatting with the business recently.
Enforcement: DeliveryService.deliverWhatsApp() checks for contentSid. If absent and the message is business-initiated, log a warning. Twilio will return error code 63016 if the message is rejected.
Template approval flow:
- Space builder creates a template in GoGrab UI with WhatsApp content
- Builder submits the template to Meta for approval (via Twilio Console or Meta Business Manager — outside GoGrab's scope)
- Once approved, builder enters the
contentSidin the template's WhatsApp content field - GoGrab uses the
contentSidwhen sending
Future improvement: Automate template submission via Twilio Content API or Meta WhatsApp Business Management API. Low priority — most businesses manage templates directly in Twilio Console.
Message categories
WhatsApp classifies business-initiated messages into:
- Utility (order updates, account notifications) — lower cost, higher approval rate
- Marketing (promotions, announcements) — higher cost, stricter approval
- Authentication (OTPs) — auto-approved, lowest cost
CommTemplateDoc should include a whatsappCategory field for cost awareness, but enforcement is on Meta's side.
14. Risks & Mitigations
Critical Issues
C1. WhatsApp template approval is out of GoGrab's control (Confidence: Certain)
- Meta can reject templates. Builders must use pre-approved
contentSidfor business-initiated messages. - Impact: WhatsApp channel won't work for business communications without approved templates.
- Mitigation: Document clearly in UI. Provide template guidelines that maximize approval likelihood. Offer email as default fallback.
C2. PlatformUser has no phone field today (Confidence: Certain)
- WhatsApp and SMS channels cannot target L2 users until
phoneis added to the schema. - Impact: Only email channel works out of the box.
- Mitigation: Add
phone+phoneCountryCodetoPlatformUserschema (Section 4). Include in registration forms for universes that need WhatsApp/SMS.
C3. BYOT credential security (Confidence: Certain)
VittoIdentitystores Twilio credentials (accountSid, authToken) in MongoDB. If the database is compromised, these credentials are exposed.- Current state: This is an existing risk, not introduced by this spec. Already accepted by the team.
- Mitigation: Encrypt credential fields at rest (MongoDB Client-Side Field Level Encryption or application-level AES). Future improvement.
Non-Critical Improvements
N1. Email open/click tracking — SendGrid provides webhook events for opens and clicks. Wiring these into DeliveryLogDoc would give space builders campaign analytics. Not needed for MVP — SendGrid's own dashboard covers this initially.
N2. Customer preference center — A hosted page where customers manage their channel preferences across all a space builder's universes. Build when multiple channels are active for L2 users.
N3. Template versioning — Track template edit history for audit and rollback. Add a versions array or use MongoDB change streams. Low priority.
N4. Webhook-based notification triggers — Allow space builders to configure "when webhook X is received, send template Y to user Z." This bridges external systems (Shopify, Stripe, custom apps) into GoGrab communications. Build after Singularity webhook integration is proven.
Assumptions to Verify
- A1:
RedisModulefrom@libs-api/backend-coreis already imported inbackend-api'sAppModule. Verified: yes, it's used for throttling. - A2:
EmailService.sendRaw()andsendWithTemplate()can accept per-call credential overrides (apiKey, from). CurrentlyEmailServicereads from env vars. Need to add optional params. - A3:
EventEmitter2is available inbackend-api. Verified:@nestjs/event-emitteris a dependency (used by Singularity). - A4:
PlatformUserschema changes require a migration for existing L2 users (addingphonefield). Since it's optional and defaults to undefined, no migration is needed — existing docs remain valid.
Phased Implementation
Phase 1 — Foundation (start here)
CommunicationsModulewithTemplateService,DeliveryService,ConsentService,DeliveryLogServiceCommTemplateDocandCustomerConsentDocschemas- Email channel only (most L2 users have email, no template approval needed)
- Template CRUD endpoints
- Single-recipient send endpoint
- Auto-consent on L2 user registration (email)
Phase 2 — Broadcast & WhatsApp
BroadcastServicewith batchingRateLimiterService(Redis)- WhatsApp delivery via BYOT credentials
phonefield onPlatformUser- WhatsApp consent flow (inbound opt-in)
- Delivery status webhook integration
Phase 3 — Flow & Event Integration
- Singularity
executeNotificationAction()wired toDeliveryService - Ecommerce
order.status_changedevent listener - Admin broadcast UI
Phase 4 — Intelligence (optional)
- AI enrichment via Vitto bridge
- Smart send-time optimization
- Customer preference center
- Campaign analytics dashboard