SMS & push notifications
131344 SMS gateway (Mongolia carrier) + Firebase Cloud Messaging (Android, iOS, web). Both outbox-driven, both retryable.
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
| Trigger | Template | Recipient |
|---|---|---|
| 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
- SMS client (Phase 1 — needed for OTP).
- Push client (Phase 5).
- Schemas:
fcm_tokens,notifications(in-app inbox). - Outbox handlers:
sms.send,push.sendin workers. - Template helpers (i18n via
i18next): one key per message type. - Flutter: token registration on app open, deep-link routing on notification tap.
- Web push: separate phase. FCM web SDK + service worker.
Open questions
- Web push parity? FCM web is free. Useful for desktop tenants. Defer to Phase 6+.
- SMS budget cap? 131344 is per-message billed. Owner-level monthly cap with alert at 80%?
- SMS language: Mongolian default; English opt-in per profile?
- Quiet hours: don't send non-critical SMS/push between 22:00–08:00 Asia/Ulaanbaatar?