Vox Pet Digital は、ペットショップと動物病院向けの垂直型 SaaS です。決して小規模なプロジェクトではありません。Prisma モデル 95 個、フロントエンド 91 ページ、機能モジュール 22 個、OpenAI、WhatsApp、Stripe、Mercado Pago、Asaas、NF-e との連携、そして同一プロセス上で稼働する Express から NestJS への進行中の移行があります。本ケーススタディでは、私が直面した 4 つの最大の技術的課題を詳しく説明します。
システム概要
Vox Pet は、動物病院の業務サイクル全体をカバーします。予約、診療記録、ワクチン、処方、入院、販売、レジ、在庫、コミッション、NF-e、そして WhatsApp 経由の自動応対まで対応します。マルチテナント(各クリニックが独立したテナント)であり、マルチ支店(クリニックチェーンが拠点間でデータを共有し、きめ細かい制御を行う)でもあります。
- バックエンド: Node.js — Express (v1) + NestJS (v2) が同一プロセスで共存
- フロントエンド: Next.js 16 + React 19(App Router 使用)、MUI v7 + shadcn/ui + Tailwind v4
- データベース: Prisma 経由の PostgreSQL 16 — 95 モデル、うち 93 が tenantid、76 が branchid を持つ
- AI: OpenAI(GPT-4o-mini + embeddings + Whisper)+ Baileys(セルフホスト型 WhatsApp)
- 決済: Stripe(SaaS サブスクリプション)+ Mercado Pago + Asaas(ブラジル国内決済)
- 電子インボイス: Focus NFe + NFe.io(フォールバック付きの 2 つのプロバイダー)
- ストレージ: Firebase Admin
- リード: Meta/Facebook 連携
課題 #1: 同一プロセスでの Express → NestJS 段階的移行
私がプロジェクトに参加したとき、バックエンドは 41 個のコントローラーを持つ Express モノリスでした。強い型付けも、DTO も、一貫したバリデーションもありませんでした。一度にすべてを書き直すのは現実的ではありませんでした。システムは本番稼働中で、クリニックが日々それに依存していたからです。
解決策は strangler fig パターンでした。Express と NestJS を同一の Node.js プロセスで稼働させます。v2 のルートは /api/v2 に存在し、一方で v1 はこれまで通り正常に動作し続けます。両者は同じ Prisma インスタンス、同じ認証システム、同じトレーサーを共有します。
あらゆる v2 コードに対するルールは厳格です:
- TypeScript strict —
any禁止、// @ts-ignore禁止 - Thin controllers — ビジネスロジックは service に置き、コントローラーはルーティングのみ
- バリデーション付き DTO — すべての入力は class-validator を通す
- tenant_id 必須 — テナントフィルターのないクエリは存在しない
console.logゼロ — すべてコンテキスト付きの構造化ロガー経由
これまでに 12 個のモジュールが v2 へ移行されました。pets(17 エンドポイント)、hospitalizations(8)、procedures(6)、branches、stock-transfers(5 + approve/reject/complete ワークフロー)、reminders、fiscal/NF-e(11)、imports(NF-e XML)、公開予約(booking)、そして analytics です。残り — 顧客、販売、予約、診療記録、ワクチン、レジ、WhatsApp、admin — はまだ v1 で稼働しており、段階的に移行されていきます。
// bootstrap.ts — Express と NestJS が共存
const expressApp = express()
// v1 ルート(レガシー)
expressApp.use('/api/v1', authMiddleware, v1Router)
// v2 ルート(NestJS)
const nestApp = await NestFactory.create(AppModule)
nestApp.setGlobalPrefix('api/v2')
const nestAdapter = nestApp.getHttpAdapter().getInstance()
expressApp.use(nestAdapter)
// 共有: Prisma、認証、トレーサー
expressApp.listen(PORT)課題 #2: 24 時間稼働のセルフホスト型 WhatsApp + AI
WhatsApp 経由の応対は、Vox Pet の最大の差別化要素の 1 つです。単純なチャットボットではありません。10 個のツールを持つ AI エージェントであり、RAG(ビジネスのナレッジベース)、会話メモリ、メディア処理(Whisper による音声)、そして自動フォローアップを備えています。
アーキテクチャは次のとおりです:
- 接続: Baileys(セルフホスト型 WhatsApp Web)を Railway 上で永続ディスク付きで稼働させ、セッションを維持
- Orchestrator: メッセージを受信し、テナントを特定し、コンテキスト(履歴 + ナレッジベース)をロードし、どのツールを使うか判断する
- 利用可能な 10 個のツール: 予約の登録、空き時間の確認、価格の照会、商品の推薦、診療記録の検索、リマインダー送信など
- RAG: テナントごとのナレッジベースに OpenAI embeddings を使用し、回答を文脈づけるためのセマンティック検索
- Whisper: 顧客が音声を送ってきた場合、自動的に文字起こしし、テキストとして処理する
- メモリ: 顧客ごとの会話履歴を保持し、メッセージ間でコンテキストを維持する
- フォローアップ: 毎分実行される cron が 15〜240 分のウィンドウ内で返信のない会話を確認し、自動フォローアップを送信する
// 簡略化した Orchestrator
async function handleMessage(tenantId: string, message: WAMessage) {
const tenant = await loadTenantConfig(tenantId)
const history = await getConversationHistory(message.from, tenantId)
const knowledge = await ragSearch(message.text, tenantId)
// 音声の場合は、まず Whisper で文字起こしする
const text = message.type === 'audio'
? await whisperTranscribe(message.media)
: message.text
const response = await openai.chat({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: buildSystemPrompt(tenant, knowledge) },
...history,
{ role: 'user', content: text },
],
tools: getAvailableTools(tenant),
})
// tool call があれば実行する
if (response.tool_calls) {
for (const call of response.tool_calls) {
await executeToolCall(call, tenantId)
}
}
await sendWhatsAppMessage(message.from, response.content)
await saveToHistory(message.from, tenantId, text, response.content)
}ここでの最大の課題は AI ではありませんでした。信頼性でした。Baileys は自動的に再接続しますが、Railway がコンテナを再起動するとセッションが失われることがあります。ボットが通知なしにオフラインになることが決してないよう、永続ディスク + ヘルスチェック + アラートを実装しました。
課題 #3: 一貫したマルチテナント + マルチ支店
SaaS におけるマルチテナントは一般的です。テナント内のマルチ支店はさらに高い複雑さのレベルです。Vox Pet では、クリニックチェーンが 3 つの支店を持ち、顧客とペットの登録情報を共有できますが、各支店はそれぞれ独自の在庫、レジ、スケジュール、コミッションを持ちます。
Prisma の 95 モデルのうち:
- 93 モデルが
tenant_idを持つ — クリニック間の完全な分離 - 76 モデルが
branch_idを持つ — テナント内での支店ごとの分離 - 15 回のマイグレーションで、既存データを壊さずにこの構造に到達
最も複雑なケースは、支店間の在庫移動です。このフローには 3 つの状態(保留中 → 承認/却下 → 完了)があり、専用の 5 エンドポイントと承認ワークフローを備えています。移動元の支店が要求し、移動先の支店が承認または却下し、その後初めて在庫がアトミックに移動されます。
// 支店間の在庫移動 — アトミックなトランザクション
async function completeTransfer(transferId: string, tenantId: string) {
return prisma.$transaction(async (tx) => {
const transfer = await tx.stockTransfer.findUnique({
where: { id: transferId, tenantId },
include: { items: true },
})
if (transfer.status !== 'APPROVED') {
throw new BadRequestException('Transfer must be approved first')
}
for (const item of transfer.items) {
// 移動元の支店から減算
await tx.stock.update({
where: { productId_branchId: { productId: item.productId, branchId: transfer.fromBranchId } },
data: { quantity: { decrement: item.quantity } },
})
// 移動先の支店へ追加
await tx.stock.upsert({
where: { productId_branchId: { productId: item.productId, branchId: transfer.toBranchId } },
create: { productId: item.productId, branchId: transfer.toBranchId, tenantId, quantity: item.quantity },
update: { quantity: { increment: item.quantity } },
})
}
await tx.stockTransfer.update({
where: { id: transferId },
data: { status: 'COMPLETED', completedAt: new Date() },
})
})
}課題 #4: 2 つのプロバイダーとフォールバックを備えた電子インボイス発行
ブラジルでの NF-e(電子インボイス)発行は重要です。インボイスのプロバイダーがダウンすると、クリニックは販売できなくなります。私たちは 2 つのプロバイダー(Focus NFe と NFe.io)を自動フォールバック付きで統合しました。v2 の cron ジョブが 30 秒ごとにキューを処理します。
- 11 エンドポイントを v2 モジュールで NF-e 専用に用意
- XML インポート — クリニックは仕入先から受領したインボイスをインポートできる
- 回復力のあるキュー — プライマリのプロバイダーが失敗すると、自動的にセカンダリを試す
- 30 秒ごとの処理 — バースト的な呼び出しを避け、プロバイダーのレート制限を尊重する
システムの規模
システムの実際の複雑さを示すいくつかの数字です:
- Prisma に 95 モデル(複雑なリレーショナルデータベース)
- フロントエンドに 91 ページ(うち 86 が稼働中)
- フロントエンドに 22 個の機能モジュール
- 41 個の Express コントローラー(v1)+ 12 個の NestJS モジュール(v2)
- 外部連携の 14 サービス
- 稼働中の 4 つの cron ジョブ(WhatsApp フォローアップ、NF-e、リマインダー、analytics)
- WhatsApp の AI エージェントの 10 個のツール
学んだ教訓
- strangler fig は機能する。 同一プロセス内で段階的に移行することは、ビッグバン方式よりも安全です。鍵は、新しいコードに対して厳格なルールを設け、決して緩めないことです
- セルフホスト型 WhatsApp は脆い。 Baileys で解決できますが、永続ディスクと監視を備えた専用インフラが必要です。もう一度ゼロから始められるなら、より大きなテナント向けには公式の WhatsApp Business API を検討するでしょう
- マルチ支店はマルチテナントより 3 倍複雑。 単に branch_id を追加するだけではありません。モデルごとに異なるビジネスルールがあります。在庫は支店ごと、顧客はテナントごと、コミッションは支店ごと、ペットはテナントごとです
- 重要な連携におけるフォールバックは任意ではない。 Focus NFe が 2 時間ダウンし、誰も気づかないうちに NFe.io が引き継いだ日が、この投資が報われた日でした
--- プロジェクトについてもっと知りたいですか? Vox Pet Digital の専用ページにアクセスしてください。
