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:
defiQUG
2026-06-19 16:17:10 -07:00
co-authored by Cursor
parent 4e4df13ab1
commit 05978ae25c
66 changed files with 8233 additions and 3903 deletions
+3 -284
View File
@@ -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}`)
})