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:

What this provides:

  1. Template registry — per-space message templates with variable substitution, editable by space builders
  2. Multi-channel delivery — WhatsApp (Twilio + Cloud API), email (SendGrid), SMS, with per-space BYOT credentials
  3. Customer consent management — opt-in/opt-out tracking for GDPR/TCPA/WhatsApp Business Policy compliance
  4. Broadcast & targeting — send to all customers, filtered by universe/role/custom segments
  5. Delivery log & analytics — audit trail, delivery status, open/click tracking
  6. Singularity integration — flow engine notification blocks trigger real deliveries
  7. Optional AI enrichment — Vitto can personalize message content when requested

Design principles:


Table of Contents

  1. Architecture Overview
  2. Module Structure
  3. Template Registry
  4. Customer Contact & Consent
  5. Delivery Service
  6. Broadcast & Targeting
  7. Delivery Log
  8. Singularity Flow Integration
  9. Optional AI Enrichment
  10. Schemas
  11. API Endpoints
  12. Key Files to Create/Modify
  13. WhatsApp Compliance
  14. 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


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:

  1. Space builder creates a template in GoGrab UI with WhatsApp content
  2. Builder submits the template to Meta for approval (via Twilio Console or Meta Business Manager — outside GoGrab's scope)
  3. Once approved, builder enters the contentSid in the template's WhatsApp content field
  4. GoGrab uses the contentSid when 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:

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)

C2. PlatformUser has no phone field today (Confidence: Certain)

C3. BYOT credential security (Confidence: Certain)

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


Phased Implementation

Phase 1 — Foundation (start here)

Phase 2 — Broadcast & WhatsApp

Phase 3 — Flow & Event Integration

Phase 4 — Intelligence (optional)