/** * MoreminiMore production server. * * Serves Astro static build (dist/) + handles POST /api/contact via Amazon SES * + POST /api/conversions for Meta CAPI + Google Enhanced Conversions. * * Env vars: * SES_ACCESS_KEY_ID — AWS IAM key with ses:SendEmail * SES_SECRET_ACCESS_KEY — AWS IAM secret * SES_REGION — default: ap-southeast-1 * PORT — default: 4321 * META_PIXEL_ID — default: 418349260078648 * META_ACCESS_TOKEN — required for Meta CAPI * GA4_MEASUREMENT_ID — default: G-74BHREDLC3 * GA4_API_SECRET — required for Google Enhanced Conversions */ import express from 'express'; import cors from 'cors'; import nodemailer from 'nodemailer'; import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { readFileSync, existsSync } from 'node:fs'; import crypto from 'node:crypto'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = parseInt(process.env.PORT || '4321', 10); const DIST = join(__dirname, 'dist'); // ── SES ──────────────────────────────────────────────────────────── const sesConfigured = !!process.env.SES_ACCESS_KEY_ID; let transporter; if (sesConfigured) { const ses = new SESv2Client({ region: process.env.SES_REGION || 'ap-southeast-1', credentials: { accessKeyId: process.env.SES_ACCESS_KEY_ID, secretAccessKey: process.env.SES_SECRET_ACCESS_KEY, }, }); transporter = nodemailer.createTransport({ SES: { sesClient: ses, SendEmailCommand }, }); } // ── Helpers ──────────────────────────────────────────────────────── const problemLabels = { website_no_leads: 'เว็บมีอยู่แล้ว แต่ไม่ค่อยมีลูกค้าทัก', ads_not_worth_it: 'ยิงแอดอยู่ แต่ยอดขายไม่คุ้ม', wrong_leads: 'มีคนทักมา แต่ไม่ใช่ลูกค้าที่ใช่', slow_or_error_work: 'ทีมงานทำงานช้า หรือผิดพลาดบ่อย', ai_not_sure: 'อยากใช้ AI แต่ไม่รู้เริ่มตรงไหน', not_sure: 'ยังไม่แน่ใจว่าควรแก้อะไรก่อน', }; function esc(text) { return String(text) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ── Routes ───────────────────────────────────────────────────────── const app = express(); app.use(cors()); app.use(express.json({ limit: '16kb' })); // POST /api/contact app.post('/api/contact', async (req, res) => { try { if (!sesConfigured) { console.warn('[api/contact] SES not configured — logging payload'); console.info('[api/contact]', JSON.stringify(req.body, null, 2)); return res.json({ ok: true, devMode: true }); } const { name, phone, email, problems = [], message = '', pageUrl = '', userAgent = '', website = '' } = req.body; // Validation if (!name || !String(name).trim()) { return res.status(400).json({ ok: false, error: 'กรุณากรอกชื่อ' }); } if ((!phone || !String(phone).trim()) && (!email || !String(email).trim())) { return res.status(400).json({ ok: false, error: 'กรุณากรอกเบอร์โทรหรืออีเมลอย่างน้อยหนึ่งอย่าง' }); } // Honeypot if (website && String(website).trim().length > 0) { console.warn('[api/contact] Honeypot triggered'); return res.json({ ok: true }); // silent success for bots } const problemsText = problems.length ? problems.map((k) => `- ${problemLabels[k] || k}`).join('\n') : '(ไม่ได้เลือก)'; const msg = String(message || '(ไม่ได้กรอก)'); const displayPhone = String(phone || '(ไม่ได้กรอก)'); const displayEmail = String(email || '(ไม่ได้กรอก)'); await transporter.sendMail({ from: 'MoreminiMore ', to: 'kunthawat@moreminimore.com', replyTo: email && email.includes('@') ? email : undefined, subject: `Contact: ${name} — ${problems.length} ปัญหา`, text: [ `ข้อความใหม่จาก ${name}`, '', `ปัญหาที่เลือก:`, problemsText, '', `รายละเอียด:`, msg, '', `ข้อมูลติดต่อ:`, `ชื่อ: ${name}`, `เบอร์: ${displayPhone}`, `อีเมล: ${displayEmail}`, '', `---`, `ส่งจากหน้า: ${pageUrl}`, `UA: ${userAgent}`, ].join('\n'), html: [ `

ข้อความใหม่จาก ${esc(name)}

`, `

ปัญหาที่เลือก

`, `

${esc(problemsText).replace(/\n/g, '
')}

`, `

รายละเอียด

`, `

${esc(msg)}

`, `

ข้อมูลติดต่อ

`, ``, ` `, ` `, ` `, `
ชื่อ${esc(name)}
เบอร์${esc(displayPhone)}
อีเมล${esc(displayEmail)}
`, `
`, `

ส่งจาก ${esc(pageUrl)}
UA: ${esc(userAgent)}

`, ].join('\n'), }); console.info(`[api/contact] Sent email from ${name} <${email || 'no email'}>`); return res.json({ ok: true }); } catch (err) { console.error('[api/contact] Error:', err); return res.status(500).json({ ok: false, error: err instanceof Error ? err.message : 'เกิดข้อผิดพลาด กรุณาลองใหม่ภายหลัง', }); } }); // ── Meta CAPI + Google Enhanced Conversions ──────────────────────── const META_PIXEL_ID = process.env.META_PIXEL_ID || '418349260078648'; const META_ACCESS_TOKEN = process.env.META_ACCESS_TOKEN || ''; const GA4_MEASUREMENT_ID = process.env.GA4_MEASUREMENT_ID || 'G-74BHREDLC3'; const GA4_API_SECRET = process.env.GA4_API_SECRET || ''; function sha256(str) { if (!str) return null; return crypto.createHash('sha256') .update(String(str).trim().toLowerCase()) .digest('hex'); } app.post('/api/conversions', async (req, res) => { const { event_name, event_id, event_time, event_source_url, user_data = {}, custom_data = {} } = req.body; const results = { meta: null, google: null }; // ── Meta CAPI ── if (META_ACCESS_TOKEN) { try { const metaBody = { data: [{ event_name, event_time, event_source_url, action_source: 'website', event_id, user_data: { em: sha256(user_data.em), ph: user_data.ph ? sha256(String(user_data.ph).replace(/[^0-9+]/g, '')) : null, fn: sha256(user_data.fn), ln: sha256(user_data.ln), fbp: user_data.fbp, fbc: user_data.fbc, client_ip_address: req.ip || req.headers['x-forwarded-for'] || req.socket.remoteAddress, client_user_agent: req.headers['user-agent'], }, custom_data, }], }; const metaRes = await fetch( `https://graph.facebook.com/v22.0/${META_PIXEL_ID}/events?access_token=${META_ACCESS_TOKEN}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(metaBody), } ); results.meta = await metaRes.json(); console.info('[api/conversions] Meta CAPI:', JSON.stringify(results.meta)); } catch (e) { console.error('[api/conversions] Meta CAPI error:', e); results.meta = { error: e.message }; } } else { console.warn('[api/conversions] META_ACCESS_TOKEN not configured — skipping Meta CAPI'); } // ── Google Enhanced Conversions (Measurement Protocol) ── if (GA4_API_SECRET) { try { const gaBody = { client_id: user_data.fbp || crypto.randomUUID(), events: [{ name: event_name.toLowerCase(), params: { engagement_time_msec: '100', session_id: event_id, ...(custom_data || {}), }, }], }; // Add user properties if available if (user_data.em || user_data.ph) { gaBody.user_properties = {}; if (user_data.em) gaBody.user_properties.user_email = { value: sha256(user_data.em) }; if (user_data.ph) gaBody.user_properties.user_phone = { value: sha256(String(user_data.ph).replace(/[^0-9+]/g, '')) }; } const gaRes = await fetch( `https://www.google-analytics.com/mp/collect?measurement_id=${GA4_MEASUREMENT_ID}&api_secret=${GA4_API_SECRET}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gaBody), } ); results.google = { status: gaRes.status, ok: gaRes.ok }; console.info('[api/conversions] Google MP:', JSON.stringify(results.google)); } catch (e) { console.error('[api/conversions] Google MP error:', e); results.google = { error: e.message }; } } else { console.warn('[api/conversions] GA4_API_SECRET not configured — skipping Google Enhanced Conversions'); } res.json({ ok: true, results }); }); // Static files if (existsSync(DIST)) { app.use(express.static(DIST, { maxAge: '1y', etag: true })); // SPA-style fallback for client-side routes // Express 5 + path-to-regexp v8: use /{*splat} syntax instead of '*' app.get('/{*splat}', (req, res) => { // Don't intercept API or asset requests if (req.path.startsWith('/api/') || req.path.includes('.')) { return res.status(404).end(); } res.sendFile(join(DIST, '404.html')); }); } else { console.warn(`[server] dist/ not found — static files not served`); } // ── Start ────────────────────────────────────────────────────────── app.listen(PORT, '0.0.0.0', () => { console.log(`[server] Listening on http://0.0.0.0:${PORT}`); console.log(`[server] SES: ${sesConfigured ? 'configured ✓' : 'NOT configured (dev mode)'}`); console.log(`[server] Meta CAPI: ${META_ACCESS_TOKEN ? 'configured ✓' : 'NOT configured'}`); console.log(`[server] Google EC: ${GA4_API_SECRET ? 'configured ✓' : 'NOT configured'}`); console.log(`[server] Static: ${existsSync(DIST) ? 'dist/ ✓' : 'dist/ NOT found'}`); });