SMS & push notifications

131344 SMS gateway (Mongolia carrier) + Firebase Cloud Messaging (Android, iOS, web). Both outbox-driven, both retryable.

Phase 5 (some Phase 1 for OTP)

SMS — 131344 gateway

Existing .NET client (v1's SMSCenterService) — port shape: HTTP POST with credentials + recipient + body. Each SMS becomes an outbox row.

// packages/integrations/src/sms/131344.ts
interface SmsConfig { gatewayUrl: string; username: string; password: string; senderId: string; }

export class Sms131344 {
  constructor(private cfg: SmsConfig) {}

  async send(phone: string, body: string): Promise<{ messageId: string }> {
    // body shape per 131344 docs (port from v1)
    const res = await fetch(this.cfg.gatewayUrl, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        username: this.cfg.username,
        password: this.cfg.password,
        from: this.cfg.senderId,
        to: normalizePhone(phone),     // E.164 without leading +
        text: body,
      }),
    });
    if (!res.ok) throw new Error(`SMS gateway error: ${res.status}`);
    return parse131344Response(await res.text());
  }
}

function normalizePhone(s: string): string {
  // Mongolia: 8-digit numbers, prefix 976. Strip spaces, leading 0, leading +.
  const d = s.replace(/[^\d]/g, "");
  if (d.length === 8) return "976" + d;
  if (d.length === 11 && d.startsWith("976")) return d;
  throw new Error(`Invalid MN phone: ${s}`);
}

SMS use cases

TriggerTemplateRecipient
OTP signup/login"Spacehub код: {code}. 5 мин дотор оруулна уу."Tenant being signed up
Contract expiry (5 days)"Таны {property} {unit} тооцоо {date}-нд дуусна."Tenant
Bill issued"{month} төлбөр: ₮{amount}. {dueDate}-ийн дотор төлнө үү."Tenant
Payment confirmed"Төлбөр ₮{amount} хүлээн авлаа. Баярлалаа."Tenant
Bill overdue (1 day after due)"Төлбөрийн хугацаа хэтэрсэн: ₮{amount}."Tenant

Push — Firebase Cloud Messaging

Use firebase-admin v13+. Legacy send(), sendAll(), sendMulticast() deprecated as of 2024-06-21. Use sendEach() and sendEachForMulticast().

pnpm --filter @spacehub/integrations add firebase-admin

// packages/integrations/src/push/firebase.ts
import { initializeApp, cert } from "firebase-admin/app";
import { getMessaging } from "firebase-admin/messaging";

const app = initializeApp({
  credential: cert(JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_JSON!)),
});
const msg = getMessaging(app);

export async function sendToTokens(tokens: string[], notification: { title: string; body: string }, data?: Record<string,string>) {
  // chunk to 500 per batch (FCM limit), parallel batches
  const chunks = chunk(tokens, 500);
  const results = await Promise.all(chunks.map(t => msg.sendEachForMulticast({
    tokens: t, notification, data,
    android: { notification: { sound: "default" } },
    apns: { payload: { aps: { sound: "default" } } },
  })));
  const dead: string[] = [];
  results.forEach((r, ci) => r.responses.forEach((res, ti) => {
    if (!res.success && res.error?.code === "messaging/registration-token-not-registered") {
      dead.push(chunks[ci][ti]);
    }
  }));
  if (dead.length) await db.delete(fcmTokens).where(inArray(fcmTokens.token, dead));
  return { sent: results.flatMap(r => r.responses).filter(r => r.success).length, dead: dead.length };
}

Token management (Drizzle)

// packages/db/src/schema/fcm-tokens.ts
export const fcmTokens = pgTable("fcm_tokens", {
  id: uuid().primaryKey().defaultRandom(),
  userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
  token: text().notNull().unique(),
  platform: text({ enum: ["ios","android","web"] }).notNull(),
  deviceLabel: text("device_label"),
  lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
  createdAt: timestamp({ withTimezone: true }).notNull().defaultNow(),
}, (t) => [index("fcm_user").on(t.userId)]);

Flutter app foreground hook → upsert token + bump lastSeenAt. BullMQ weekly cron prunes tokens older than 60 days.

API surface

POST   /v2/devices                     # register FCM token { token, platform, deviceLabel? }
DELETE /v2/devices/{token}             # unregister (logout)
GET    /v2/notifications?cursor=&limit= # in-app notification feed
POST   /v2/notifications/{id}/read     # mark as read
POST   /v2/notifications/read-all

Build steps

  1. SMS client (Phase 1 — needed for OTP).
  2. Push client (Phase 5).
  3. Schemas: fcm_tokens, notifications (in-app inbox).
  4. Outbox handlers: sms.send, push.send in workers.
  5. Template helpers (i18n via i18next): one key per message type.
  6. Flutter: token registration on app open, deep-link routing on notification tap.
  7. Web push: separate phase. FCM web SDK + service worker.

Open questions

Sources