feat: 購入後マジックリンクメール自動送信 + TTS に purchaseMiddleware 接続
Made-with: Cursor
This commit is contained in:
parent
5f371c3eee
commit
10402464c5
|
|
@ -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 エントリーポイント ──
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue