feat: 購入後マジックリンクメール自動送信 + TTS に purchaseMiddleware 接続

Made-with: Cursor
This commit is contained in:
posimai 2026-04-11 15:05:23 +09:00
parent 5f371c3eee
commit 10402464c5
2 changed files with 72 additions and 4 deletions

View File

@ -94,6 +94,69 @@ module.exports = function createStripeHandler(pool) {
await sendLicenseEmail(email, licenseKey); await sendLicenseEmail(email, licenseKey);
} }
// ── 購入完了メール(マジックリンク付き)送信 ──
async function sendWelcomeMagicLink(email) {
if (!RESEND_API_KEY) {
console.warn('[Stripe] RESEND_API_KEY not set — skipping welcome email');
return;
}
try {
// マジックリンク用トークンを生成15分有効
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
await pool.query(
`INSERT INTO magic_link_tokens (email, token, expires_at) VALUES ($1, $2, $3)`,
[email, token, expiresAt]
);
const loginBase = process.env.MAGIC_LINK_BASE_URL || 'https://posimai.soar-enrich.com';
const magicLinkUrl = `${loginBase}/auth/verify?token=${token}&redirect=${encodeURIComponent('https://brief.posimai.soar-enrich.com')}`;
const emailRes = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${RESEND_API_KEY}`,
},
body: JSON.stringify({
from: FROM_EMAIL,
to: email,
subject: 'Posimai へようこそ — ログインリンク',
html: `
<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
<h2 style="font-size:20px;font-weight:600;margin-bottom:8px">ご購入ありがとうございます</h2>
<p style="color:#555;line-height:1.6">Posimai パスの購入が完了しました<br>
下のボタンをクリックするとすぐにアプリをご利用いただけます</p>
<div style="margin:24px 0">
<a href="${magicLinkUrl}"
style="display:inline-block;background:#6EE7B7;color:#0D0D0D;
font-weight:600;padding:12px 28px;border-radius:8px;
text-decoration:none;font-size:15px">
Posimai を開く
</a>
</div>
<p style="font-size:12px;color:#888">このリンクは15分間有効です<br>
期限切れの場合は <a href="${loginBase}/login">${loginBase}/login</a> </p>
<hr style="border:none;border-top:1px solid #eee;margin:24px 0">
<p style="font-size:12px;color:#aaa">
ご不明な点は <a href="mailto:${APP_SUPPORT_EMAIL}">${APP_SUPPORT_EMAIL}</a>
</p>
</div>
`,
}),
});
if (!emailRes.ok) {
const errBody = await emailRes.text();
console.error('[Stripe] Welcome email error:', emailRes.status, errBody);
} else {
console.log(`[Stripe] Welcome magic link sent to ${email}`);
}
} catch (e) {
console.error('[Stripe] sendWelcomeMagicLink error:', e.message);
}
}
// ── Posimai 購読購入処理(既存フロー) ── // ── Posimai 購読購入処理(既存フロー) ──
async function handlePosiPurchase(session) { async function handlePosiPurchase(session) {
const email = session.customer_details?.email?.toLowerCase(); const email = session.customer_details?.email?.toLowerCase();
@ -121,6 +184,11 @@ module.exports = function createStripeHandler(pool) {
[session.id, session.customer, session.subscription, userId] [session.id, session.customer, session.subscription, userId]
); );
console.log(`[Stripe] Plan upgraded to premium for ${email}`); console.log(`[Stripe] Plan upgraded to premium for ${email}`);
// 購入完了メールにマジックリンクを同梱(非同期・失敗しても購入処理には影響しない)
sendWelcomeMagicLink(email).catch(e =>
console.error('[Stripe] Welcome email failed (non-critical):', e.message)
);
} }
// ── Webhook エントリーポイント ── // ── Webhook エントリーポイント ──

View File

@ -2159,8 +2159,8 @@ ${excerpt}
return Buffer.from(await synthRes.arrayBuffer()); return Buffer.from(await synthRes.arrayBuffer());
} }
// POST /tts — テキストを音声WAVに変換して返す // POST /tts — テキストを音声WAVに変換して返す(購入済みユーザーのみ)
r.post('/tts', authMiddleware, async (req, res) => { r.post('/tts', authMiddleware, purchaseMiddleware, async (req, res) => {
const { text, speaker = 1 } = req.body || {}; const { text, speaker = 1 } = req.body || {};
if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' }); if (!text || typeof text !== 'string') return res.status(400).json({ error: 'text required' });
if (text.length > 600) return res.status(400).json({ error: 'text too long (max 600 chars)' }); if (text.length > 600) return res.status(400).json({ error: 'text too long (max 600 chars)' });
@ -2203,7 +2203,7 @@ ${excerpt}
}); });
// POST /tts/ready — 指定テキストがキャッシュ済みか確認(ポーリング用) // POST /tts/ready — 指定テキストがキャッシュ済みか確認(ポーリング用)
r.post('/tts/ready', authMiddleware, (req, res) => { r.post('/tts/ready', authMiddleware, purchaseMiddleware, (req, res) => {
const { texts, speaker = 1 } = req.body || {}; const { texts, speaker = 1 } = req.body || {};
if (!texts || !Array.isArray(texts)) return res.json({ cached: 0, total: 0, ready: false }); if (!texts || !Array.isArray(texts)) return res.json({ cached: 0, total: 0, ready: false });
const total = texts.length; const total = texts.length;
@ -2213,7 +2213,7 @@ ${excerpt}
// POST /tts/warmup — バックグラウンドで事前合成してキャッシュを温める // POST /tts/warmup — バックグラウンドで事前合成してキャッシュを温める
// ブラウザが Feed 読み込み直後に呼び出す。即座に 202 を返し、VOICEVOX をバックグラウンドで実行。 // ブラウザが Feed 読み込み直後に呼び出す。即座に 202 を返し、VOICEVOX をバックグラウンドで実行。
r.post('/tts/warmup', authMiddleware, async (req, res) => { r.post('/tts/warmup', authMiddleware, purchaseMiddleware, async (req, res) => {
const { texts, speaker = 1 } = req.body || {}; const { texts, speaker = 1 } = req.body || {};
if (!texts || !Array.isArray(texts) || texts.length === 0) { if (!texts || !Array.isArray(texts) || texts.length === 0) {
return res.status(400).json({ error: 'texts[] required' }); return res.status(400).json({ error: 'texts[] required' });