feat(mim): admin QR/brand API, public config hooks, and deploy workflow fix.
Split mim-api into modular routes, refresh nav brand assets, and pin reusable validate workflow to Order-of-Hospitallers/miracles_in_motion@main. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+3
-284
@@ -1,288 +1,7 @@
|
||||
/**
|
||||
* MIM4U API — VMID 7811
|
||||
* Routes: health, events, Stripe checkout/intent/webhook, assistance, contact, donations log
|
||||
*/
|
||||
import express from 'express'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import nodemailer from 'nodemailer'
|
||||
import Stripe from 'stripe'
|
||||
import { createApp } from './app.js'
|
||||
|
||||
const PORT = Number(process.env.PORT || 3001)
|
||||
const DATA_DIR = process.env.MIM_DATA_DIR || path.join(process.cwd(), 'data')
|
||||
const PUBLIC_URL = (process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '')
|
||||
const NOTIFY_EMAIL = process.env.MIM_NOTIFY_EMAIL || '[email protected]'
|
||||
const CORS_ORIGINS = (process.env.MIM_CORS_ORIGINS || 'https://mim4u.org,https://www.mim4u.org')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const stripeKey = process.env.STRIPE_SECRET_KEY
|
||||
const stripe = stripeKey ? new Stripe(stripeKey) : null
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
|
||||
function appendJsonl(file, record) {
|
||||
const line = `${JSON.stringify({ ...record, ts: new Date().toISOString() })}\n`
|
||||
fs.appendFileSync(path.join(DATA_DIR, file), line, 'utf8')
|
||||
}
|
||||
|
||||
function mailTransport() {
|
||||
const { SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_PORT } = process.env
|
||||
if (!SMTP_HOST) return null
|
||||
return nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: Number(SMTP_PORT || 587),
|
||||
secure: Number(SMTP_PORT) === 465,
|
||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
async function notify(subject, text, { to } = {}) {
|
||||
appendJsonl('notifications.jsonl', { subject, text, to })
|
||||
const transport = mailTransport()
|
||||
if (!transport) {
|
||||
console.log(`[notify] ${subject}: ${text.slice(0, 200)}`)
|
||||
return
|
||||
}
|
||||
await transport.sendMail({
|
||||
from: NOTIFY_EMAIL,
|
||||
to: to || NOTIFY_EMAIL,
|
||||
subject,
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
function cors(req, res, next) {
|
||||
const origin = req.headers.origin
|
||||
if (origin && (CORS_ORIGINS.includes(origin) || CORS_ORIGINS.includes('*'))) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Stripe-Signature')
|
||||
if (req.method === 'OPTIONS') return res.sendStatus(204)
|
||||
next()
|
||||
}
|
||||
|
||||
function requireStripe(_req, res, next) {
|
||||
if (!stripe) {
|
||||
return res.status(503).json({ error: 'Stripe not configured on API host (STRIPE_SECRET_KEY)' })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
function validateAssistance(body) {
|
||||
const errors = []
|
||||
if (!body?.requestType) errors.push('requestType required')
|
||||
if (!body?.studentInfo?.firstName?.trim()) errors.push('studentInfo.firstName required')
|
||||
if (!body?.studentInfo?.lastName?.trim()) errors.push('studentInfo.lastName required')
|
||||
if (!body?.studentInfo?.school?.trim()) errors.push('studentInfo.school required')
|
||||
if (!body?.contactInfo?.parentName?.trim()) errors.push('contactInfo.parentName required')
|
||||
if (!body?.contactInfo?.phone?.trim()) errors.push('contactInfo.phone required')
|
||||
if (!body?.contactInfo?.email?.trim()) errors.push('contactInfo.email required')
|
||||
if (!body?.contactInfo?.relationship?.trim()) errors.push('contactInfo.relationship required')
|
||||
if (!body?.details?.trim()) errors.push('details required')
|
||||
if (body?.website) errors.push('spam detected')
|
||||
return errors
|
||||
}
|
||||
|
||||
const app = express()
|
||||
app.disable('x-powered-by')
|
||||
app.use(cors)
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.type('text/plain').send('ok')
|
||||
})
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
service: 'mim-api',
|
||||
stripe: Boolean(stripe),
|
||||
dataDir: DATA_DIR,
|
||||
version: '1.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
app.use('/api/events', express.json({ limit: '32kb' }))
|
||||
app.post('/api/events', (req, res) => {
|
||||
appendJsonl('events.jsonl', req.body || {})
|
||||
res.status(204).end()
|
||||
})
|
||||
|
||||
app.use('/api/assistance-requests', express.json({ limit: '128kb' }))
|
||||
const postAssistanceRequest = async (req, res) => {
|
||||
const errors = validateAssistance(req.body)
|
||||
if (errors.length) return res.status(400).json({ error: 'Validation failed', fields: errors })
|
||||
|
||||
const id = `asst_${crypto.randomUUID()}`
|
||||
const record = { id, ...req.body, ip: req.ip, userAgent: req.headers['user-agent'] }
|
||||
appendJsonl('assistance-requests.jsonl', record)
|
||||
|
||||
try {
|
||||
await notify(
|
||||
`[MIM4U] Assistance request ${id}`,
|
||||
`New assistance request (${req.body.requestType})\nContact: ${req.body.contactInfo.parentName} <${req.body.contactInfo.email}>\nPhone: ${req.body.contactInfo.phone}\nDetails: ${req.body.details?.slice(0, 500)}`,
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('assistance notify failed', e)
|
||||
}
|
||||
|
||||
res.status(201).json({ ok: true, id, message: 'Request received. We will contact you within 24–48 hours.' })
|
||||
}
|
||||
app.post('/api/assistance-requests', postAssistanceRequest)
|
||||
app.post('/api/assistance', postAssistanceRequest)
|
||||
|
||||
app.use('/api/contact', express.json({ limit: '64kb' }))
|
||||
app.post('/api/contact', async (req, res) => {
|
||||
const { type, name, email, message, website, ...rest } = req.body || {}
|
||||
if (website) return res.status(400).json({ error: 'Invalid submission' })
|
||||
if (!type) return res.status(400).json({ error: 'type required' })
|
||||
if (!email?.trim() && type !== 'story') return res.status(400).json({ error: 'email required' })
|
||||
if (type === 'story' && !message?.trim() && !rest.story?.trim()) {
|
||||
return res.status(400).json({ error: 'story text required' })
|
||||
}
|
||||
|
||||
const id = `contact_${crypto.randomUUID()}`
|
||||
appendJsonl('contact.jsonl', { id, type, name, email, message, ...rest })
|
||||
try {
|
||||
await notify(`[MIM4U] ${type} form ${id}`, JSON.stringify({ name, email, message, ...rest }, null, 2))
|
||||
} catch (e) {
|
||||
console.error('contact notify failed', e)
|
||||
}
|
||||
res.status(201).json({ ok: true, id, message: 'Thank you — we will be in touch soon.' })
|
||||
})
|
||||
|
||||
app.use('/api/donations', express.json({ limit: '32kb' }))
|
||||
app.post('/api/donations', (req, res) => {
|
||||
appendJsonl('donations-offline.jsonl', req.body || {})
|
||||
res.status(201).json({ ok: true })
|
||||
})
|
||||
|
||||
app.use('/api/create-payment-intent', express.json({ limit: '32kb' }))
|
||||
app.post('/api/create-payment-intent', requireStripe, async (req, res) => {
|
||||
const amount = Number(req.body?.amount)
|
||||
if (!Number.isFinite(amount) || amount < 100) {
|
||||
return res.status(400).json({ error: 'amount must be at least 100 cents' })
|
||||
}
|
||||
try {
|
||||
const intent = await stripe.paymentIntents.create({
|
||||
amount: Math.round(amount),
|
||||
currency: 'usd',
|
||||
metadata: {
|
||||
recurring: String(Boolean(req.body?.recurring)),
|
||||
donorEmail: req.body?.customer?.email || '',
|
||||
donorName: req.body?.customer?.name || '',
|
||||
},
|
||||
receipt_email: req.body?.customer?.email || undefined,
|
||||
})
|
||||
res.json({ client_secret: intent.client_secret })
|
||||
} catch (e) {
|
||||
console.error('payment intent error', e)
|
||||
res.status(502).json({ error: e.message || 'Stripe error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.use('/api/create-checkout-session', express.json({ limit: '32kb' }))
|
||||
app.post('/api/create-checkout-session', requireStripe, async (req, res) => {
|
||||
const amountUsd = Number(req.body?.amount)
|
||||
if (!Number.isFinite(amountUsd) || amountUsd < 1) {
|
||||
return res.status(400).json({ error: 'amount must be at least $1' })
|
||||
}
|
||||
const cents = Math.round(amountUsd * 100)
|
||||
const recurring = Boolean(req.body?.recurring)
|
||||
const email = req.body?.email?.trim()
|
||||
const name = req.body?.name?.trim()
|
||||
|
||||
try {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: recurring ? 'subscription' : 'payment',
|
||||
success_url: `${PUBLIC_URL}/donate?status=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${PUBLIC_URL}/donate?status=cancelled`,
|
||||
customer_email: email || undefined,
|
||||
line_items: [
|
||||
recurring
|
||||
? {
|
||||
price_data: {
|
||||
currency: 'usd',
|
||||
product_data: { name: 'Monthly donation — Miracles in Motion Foundation' },
|
||||
unit_amount: cents,
|
||||
recurring: { interval: 'month' },
|
||||
},
|
||||
quantity: 1,
|
||||
}
|
||||
: {
|
||||
price_data: {
|
||||
currency: 'usd',
|
||||
product_data: { name: 'Donation — Miracles in Motion Foundation' },
|
||||
unit_amount: cents,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
donorName: name || '',
|
||||
anonymous: String(Boolean(req.body?.anonymous)),
|
||||
},
|
||||
})
|
||||
res.json({ url: session.url, sessionId: session.id })
|
||||
} catch (e) {
|
||||
console.error('checkout session error', e)
|
||||
res.status(502).json({ error: e.message || 'Stripe error' })
|
||||
}
|
||||
})
|
||||
|
||||
app.post(
|
||||
'/api/webhooks/stripe',
|
||||
express.raw({ type: 'application/json' }),
|
||||
async (req, res) => {
|
||||
if (!stripe) return res.status(503).send('Stripe not configured')
|
||||
const sig = req.headers['stripe-signature']
|
||||
const secret = process.env.STRIPE_WEBHOOK_SECRET
|
||||
let event
|
||||
try {
|
||||
event = secret
|
||||
? stripe.webhooks.constructEvent(req.body, sig, secret)
|
||||
: JSON.parse(req.body.toString())
|
||||
} catch (e) {
|
||||
console.error('webhook verify failed', e.message)
|
||||
return res.status(400).send(`Webhook Error: ${e.message}`)
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object
|
||||
appendJsonl('donations.jsonl', {
|
||||
sessionId: session.id,
|
||||
amountTotal: session.amount_total,
|
||||
customerEmail: session.customer_details?.email,
|
||||
metadata: session.metadata,
|
||||
})
|
||||
const donorEmail = session.customer_details?.email
|
||||
const amountUsd = ((session.amount_total || 0) / 100).toFixed(2)
|
||||
try {
|
||||
await notify(
|
||||
`[MIM4U] Donation received ${session.id}`,
|
||||
`Amount: ${amountUsd} USD\nEmail: ${donorEmail || 'n/a'}`,
|
||||
)
|
||||
if (donorEmail) {
|
||||
await notify(
|
||||
'Thank you for your gift — Miracles in Motion Foundation',
|
||||
`Dear friend,\n\nThank you for your generous donation of $${amountUsd} to Miracles in Motion Foundation.\n\nYour gift supports outreach, emergency assistance, and compassionate care in Los Angeles County.\n\nEIN ${process.env.MIM_EIN || '33-4887159'}\nhttps://mim4u.org\n\nWith gratitude,\nMiracles in Motion Foundation`,
|
||||
{ to: donorEmail },
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('donation notify failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ received: true })
|
||||
},
|
||||
)
|
||||
|
||||
app.use((_req, res) => res.status(404).json({ error: 'Not found' }))
|
||||
const { app, PORT, DATA_DIR } = createApp()
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`mim-api listening on :${PORT} (stripe=${Boolean(stripe)}) data=${DATA_DIR}`)
|
||||
console.log(`mim-api listening on :${PORT} data=${DATA_DIR}`)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user