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
+28 -1
View File
@@ -4,7 +4,16 @@ NODE_ENV=production
MIM_PUBLIC_URL=https://mim4u.org
MIM_DATA_DIR=/var/lib/mim-api/data
# Stripe (required for live donations)
# Portal auth (JWT signing — required in production)
MIM_AUTH_SECRET=change-me-to-a-long-random-string
# Portal users (seeded on first start if users.json missing)
MIM_ADMIN_PASSWORD=
MIM_VOLUNTEER_PASSWORD=
MIM_RESOURCE_PASSWORD=
# Stripe (can also be set via admin UI → persisted to admin-settings.json)
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
@@ -15,5 +24,23 @@ SMTP_USER=
SMTP_PASS=
MIM_NOTIFY_EMAIL=[email protected]
# Brand files (optional — default: $MIM_DATA_DIR/brand; set to /var/www/html/brand on 7810 for direct nginx serve)
# MIM_BRAND_DIR=/var/lib/mim-api/data/brand
# CORS (comma-separated origins; default mim4u.org)
MIM_CORS_ORIGINS=https://mim4u.org,https://www.mim4u.org
# QR codes — provider: qrcode-monkey (default) or qrcg (dynamic tracking)
# QR Code Monkey docs: https://www.qrcode-monkey.com/qr-code-api-with-logo/
# Direct API works without a key; optional RapidAPI after subscribing to
# "Custom QR Code with Logo" on rapidapi.com:
# RAPIDAPI_KEY=
# QR_MONKEY_USE_RAPIDAPI=1
# QR_MONKEY_LOGO_URL=https://mim4u.org/brand/logo-square.png
# QR_MONKEY_SIZE=600
# QRCG by Bitly — optional dynamic QR with scan analytics
# Docs: https://dev.qrcg.com/
# MIM_QR_PROVIDER=qrcg
QRCG_API_KEY=
# QRCG_LOGO_ID=
+1040
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -8,13 +8,14 @@
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test test/server.test.js"
"test": "node --test test/*.test.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^4.21.2",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.10.0",
"stripe": "^17.7.0"
}
+331
View File
@@ -0,0 +1,331 @@
/**
* MIM4U API — VMID 7811
* Public: health, events, Stripe, assistance, contact
* Admin: auth, settings, analytics, Stripe management
*/
import express from 'express'
import crypto from 'node:crypto'
import nodemailer from 'nodemailer'
import path from 'node:path'
import { readJsonl, appendJsonl, ensureDataDir } from './lib/data.js'
import { ensureUsers } from './lib/users.js'
import { loadSettings, publicConfig } from './lib/settings.js'
import { getStripeClient, isStripeLive } from './lib/stripe-service.js'
import { createCorsMiddleware } from './middleware/cors.js'
import { createAuthRoutes } from './routes/auth-routes.js'
import { createBrandPublicRoutes, createBrandAdminRoutes } from './routes/brand-routes.js'
import {
createAdminRoutes,
createResourceRoutes,
createVolunteerRoutes,
} from './routes/admin-routes.js'
import { createQrAdminRoutes } from './routes/qr-routes.js'
import { createQrPublicRoutes } from './routes/qr-public-routes.js'
import { getQrProvider, isAnyQrConfigured } from './lib/qr-provider.js'
export 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
}
export function createApp(options = {}) {
const PORT = Number(options.port || process.env.PORT || 3001)
const DATA_DIR = options.dataDir || process.env.MIM_DATA_DIR || path.join(process.cwd(), 'data')
const PUBLIC_URL = (options.publicUrl || 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 AUTH_SECRET = options.authSecret || process.env.MIM_AUTH_SECRET || 'mim-dev-auth-secret-change-me'
ensureDataDir(DATA_DIR)
ensureUsers(DATA_DIR)
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(DATA_DIR, '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 requireStripe(_req, res, next) {
if (!isStripeLive(DATA_DIR)) {
return res.status(503).json({ error: 'Stripe donations are disabled or not configured' })
}
next()
}
const app = express()
app.disable('x-powered-by')
app.use(createCorsMiddleware(CORS_ORIGINS))
app.get('/health', (_req, res) => {
res.type('text/plain').send('ok')
})
app.get('/api/health', (_req, res) => {
const settings = loadSettings(DATA_DIR)
res.json({
ok: true,
service: 'mim-api',
stripe: isStripeLive(DATA_DIR),
donationsEnabled: settings.donationsEnabled,
dataDir: DATA_DIR,
version: '1.1.0',
qr: {
provider: getQrProvider(),
configured: isAnyQrConfigured(),
},
})
})
app.get('/api/public/config', (_req, res) => {
res.json(publicConfig(loadSettings(DATA_DIR)))
})
app.get('/api/public/donation-impact', (_req, res) => {
const donations = readJsonl(DATA_DIR, 'donations.jsonl', { limit: 500 })
const totalCents = donations.reduce((s, d) => s + (d.amountTotal || 0), 0)
const assistance = readJsonl(DATA_DIR, 'assistance-requests.jsonl', { limit: 500 })
res.json({
totalRaisedUsd: Math.round(totalCents / 100),
donationCount: donations.length,
familiesSupported: assistance.filter((a) => a.status === 'completed' || a.status === 'approved').length,
})
})
app.use('/api/public', createBrandPublicRoutes({ dataDir: DATA_DIR }))
app.use('/api/public', createQrPublicRoutes({ dataDir: DATA_DIR }))
app.use('/api/auth', createAuthRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use('/api/admin', createAdminRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use('/api/admin', createBrandAdminRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use(
'/api/admin',
createQrAdminRoutes({
dataDir: DATA_DIR,
authSecret: AUTH_SECRET,
publicUrl: PUBLIC_URL,
publicApiBase: process.env.MIM_API_PUBLIC_BASE || '',
}),
)
app.use('/api/volunteer', createVolunteerRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use('/api/resource', createResourceRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use('/api/events', express.json({ limit: '32kb' }))
app.post('/api/events', (req, res) => {
appendJsonl(DATA_DIR, '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, status: 'pending', ...req.body, ip: req.ip, userAgent: req.headers['user-agent'] }
appendJsonl(DATA_DIR, '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(DATA_DIR, '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(DATA_DIR, '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 stripe = getStripeClient(DATA_DIR)
const amount = Number(req.body?.amount)
if (!stripe) return res.status(503).json({ error: 'Stripe not configured' })
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 stripe = getStripeClient(DATA_DIR)
const amountUsd = Number(req.body?.amount)
if (!stripe) return res.status(503).json({ error: 'Stripe not configured' })
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)),
recurring: String(recurring),
},
})
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) => {
const stripe = getStripeClient(DATA_DIR)
const settings = loadSettings(DATA_DIR)
if (!stripe) return res.status(503).send('Stripe not configured')
const sig = req.headers['stripe-signature']
const secret = settings.stripeWebhookSecret
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(DATA_DIR, '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' }))
return { app, DATA_DIR, PORT, AUTH_SECRET }
}
@@ -0,0 +1,96 @@
{
"version": "1.2.0",
"organization": "Miracles in Motion Foundation",
"updated": "2026-06-15",
"colors": [
{ "name": "Forest green", "hex": "#1a3c34", "role": "primary background" },
{ "name": "Forest green (deep)", "hex": "#0f2922", "role": "dark text" },
{ "name": "Gold", "hex": "#c5a059", "role": "accent" },
{ "name": "Gold (bright)", "hex": "#d4af37", "role": "logo highlight" }
],
"typography": [
{ "name": "Playfair Display", "use": "Headings (web)", "source": "Self-hosted via site" },
{ "name": "Inter", "use": "Body and UI (web)", "source": "Self-hosted via site" }
],
"kits": [
{
"id": "full",
"title": "Complete brand kit (ZIP)",
"path": "/brand/MIM4U-Brand-Kit.zip",
"format": "ZIP",
"description": "All logos, favicons, and guidelines markdown"
}
],
"groups": [
{
"id": "nav",
"title": "Website nav lockups (transparent)",
"description": "Approved header/footer assets — gold artwork on transparent background for light and dark UI shells.",
"assets": [
{ "title": "Nav horizontal (SVG)", "path": "/brand/logo-horizontal-nav.svg", "format": "SVG" },
{ "title": "Nav horizontal (PNG)", "path": "/brand/logo-horizontal-nav.png", "format": "PNG" },
{ "title": "Nav horizontal (WebP)", "path": "/brand/logo-horizontal-nav.webp", "format": "WebP" },
{ "title": "Nav symbol (SVG)", "path": "/brand/logo-symbol-nav.svg", "format": "SVG" },
{ "title": "Nav symbol (PNG)", "path": "/brand/logo-symbol-nav.png", "format": "PNG" }
]
},
{
"id": "symbol",
"title": "Symbol mark (icon only)",
"description": "Figure and starburst — mobile header, favicons, compact UI.",
"assets": [
{ "title": "Symbol (SVG, simplified vector)", "path": "/brand/logo-symbol.svg", "format": "SVG" },
{ "title": "Symbol 512px", "path": "/brand/logo-symbol-512.png", "format": "PNG" },
{ "title": "Symbol 256px", "path": "/brand/logo-symbol-256.png", "format": "PNG" },
{ "title": "Symbol 128px", "path": "/brand/logo-symbol-128.png", "format": "PNG" },
{ "title": "Symbol 64px", "path": "/brand/logo-symbol-64.png", "format": "PNG" }
]
},
{
"id": "horizontal",
"title": "Horizontal lockup",
"description": "Icon + MIRACLES IN MOTION FOUNDATION — desktop nav and footer.",
"assets": [
{ "title": "Horizontal (PNG)", "path": "/brand/logo-horizontal.png", "format": "PNG" },
{ "title": "Horizontal (WebP)", "path": "/brand/logo-horizontal.webp", "format": "WebP" },
{ "title": "Horizontal 2× width (PNG)", "path": "/brand/logo-horizontal-2x.png", "format": "PNG" }
]
},
{
"id": "square",
"title": "Square lockup",
"description": "Full stacked logo on green — social and print-friendly raster.",
"assets": [
{ "title": "Square (PNG)", "path": "/brand/logo-square.png", "format": "PNG" },
{ "title": "Square (WebP)", "path": "/brand/logo-square.webp", "format": "WebP" }
]
},
{
"id": "qr",
"title": "Branded QR codes",
"description": "Approved dynamic QR styling — forest green field, gold diamond modules, leaf corner finders. Generated via QRCG (admin /admin-qr).",
"assets": [
{
"title": "Branded QR reference (PNG)",
"path": "/brand/qr-code-branded-reference.png",
"format": "PNG",
"description": "Canonical visual reference for donate/event QR codes"
}
]
},
{
"id": "favicon",
"title": "Favicon & app icons",
"description": "Symbol on forest green background.",
"assets": [
{ "title": "favicon.ico", "path": "/brand/favicon.ico", "format": "ICO" },
{ "title": "16×16", "path": "/brand/favicon-16.png", "format": "PNG" },
{ "title": "32×32", "path": "/brand/favicon-32.png", "format": "PNG" },
{ "title": "180×180 (Apple touch)", "path": "/brand/favicon-180.png", "format": "PNG" },
{ "title": "192×192 (PWA)", "path": "/brand/favicon-192.png", "format": "PNG" },
{ "title": "512×512 (PWA)", "path": "/brand/favicon-512.png", "format": "PNG" },
{ "title": "Favicon (SVG)", "path": "/favicon.svg", "format": "SVG" }
]
}
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"version": "1.0.0",
"description": "Canonical MIM4U dynamic QR styling — forest green field, gold diamond modules, leaf corner finders, optional center logo.",
"referenceImage": "/brand/qr-code-branded-reference.png",
"colors": {
"background": "#1a3c34",
"foreground": "#c5a059",
"foregroundBright": "#d4af37"
},
"customizations": {
"frame": { "name": "no-frame" },
"background": { "color": "#1a3c34" },
"pattern": { "color": "#c5a059", "shape": "diamond" },
"corners": {
"topLeft": { "innerColor": "#c5a059", "outerColor": "#c5a059", "shape": "version17" },
"topRight": { "innerColor": "#c5a059", "outerColor": "#c5a059", "shape": "version17" },
"bottomLeft": { "innerColor": "#c5a059", "outerColor": "#c5a059", "shape": "version17" }
}
},
"logo": {
"note": "QR Code Monkey: set QR_MONKEY_LOGO_URL or upload via /qr/uploadImage → QR_MONKEY_LOGO_FILE. QRCG: set QRCG_LOGO_ID.",
"localAsset": "/brand/qr-code-branded-reference.png"
},
"qrcodeMonkey": {
"body": "diamond",
"eye": "frame14",
"eyeBall": "ball16",
"bodyColor": "#c5a059",
"bgColor": "#1a3c34",
"eye1Color": "#c5a059",
"eye2Color": "#c5a059",
"eye3Color": "#c5a059",
"eyeBall1Color": "#c5a059",
"eyeBall2Color": "#c5a059",
"eyeBall3Color": "#c5a059",
"logoMode": "clean"
}
}
+65
View File
@@ -0,0 +1,65 @@
import crypto from 'node:crypto'
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000
export function hashPassword(password) {
const salt = randomBytes(16)
const hash = scryptSync(password, salt)
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
export function verifyPassword(password, stored) {
const [saltHex, hashHex] = stored.split(':')
if (!saltHex || !hashHex) return false
const salt = Buffer.from(saltHex, 'hex')
const expected = Buffer.from(hashHex, 'hex')
const actual = scryptSync(password, salt)
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual)
}
function scryptSync(password, salt) {
return crypto.scryptSync(password, salt, 64)
}
function randomBytes(n) {
return crypto.randomBytes(n)
}
function b64url(input) {
return Buffer.from(input).toString('base64url')
}
function b64urlDecode(input) {
return Buffer.from(input, 'base64url').toString('utf8')
}
export function signToken(payload, secret) {
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = b64url(JSON.stringify({ ...payload, exp: Date.now() + TOKEN_TTL_MS }))
const sig = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url')
return `${header}.${body}.${sig}`
}
export function verifyToken(token, secret) {
if (!token || typeof token !== 'string') return null
const parts = token.split('.')
if (parts.length !== 3) return null
const [header, body, sig] = parts
const expected = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url')
if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return null
}
try {
const payload = JSON.parse(b64urlDecode(body))
if (!payload.exp || Date.now() > payload.exp) return null
return payload
} catch {
return null
}
}
export function maskSecret(value) {
if (!value || typeof value !== 'string') return ''
if (value.length <= 8) return '••••••••'
return `${value.slice(0, 7)}…${value.slice(-4)}`
}
+107
View File
@@ -0,0 +1,107 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { readJsonFile, writeJsonFile } from './data.js'
const MANIFEST_FILE = 'brand-manifest.json'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DEFAULT_MANIFEST_PATH = path.join(__dirname, '../defaults/brand-manifest.default.json')
export function brandDir(dataDir) {
return process.env.MIM_BRAND_DIR || path.join(dataDir, 'brand')
}
export function loadDefaultManifest() {
if (fs.existsSync(DEFAULT_MANIFEST_PATH)) {
try {
return JSON.parse(fs.readFileSync(DEFAULT_MANIFEST_PATH, 'utf8'))
} catch {
return null
}
}
return null
}
export function loadBrandManifest(dataDir) {
const stored = readJsonFile(dataDir, MANIFEST_FILE, null)
if (stored) {
return normalizeManifest(stored)
}
const seeded = loadDefaultManifest()
if (seeded) {
const normalized = normalizeManifest({ ...seeded, published: true })
saveBrandManifest(dataDir, normalized)
return normalized
}
return normalizeManifest({ version: '1.0.0', organization: 'Miracles in Motion Foundation', updated: new Date().toISOString().slice(0, 10), colors: [], typography: [], groups: [], kits: [], published: true })
}
export function saveBrandManifest(dataDir, manifest) {
const dir = brandDir(dataDir)
fs.mkdirSync(dir, { recursive: true })
writeJsonFile(dataDir, MANIFEST_FILE, {
...manifest,
updated: new Date().toISOString().slice(0, 10),
})
}
function normalizeManifest(m) {
return {
version: m.version || '1.0.0',
organization: m.organization || 'Miracles in Motion Foundation',
updated: m.updated || new Date().toISOString().slice(0, 10),
published: m.published !== false,
colors: Array.isArray(m.colors) ? m.colors : [],
typography: Array.isArray(m.typography) ? m.typography : [],
kits: Array.isArray(m.kits) ? m.kits : [],
groups: (m.groups || []).map((g) => ({
...g,
visible: g.visible !== false,
assets: (g.assets || []).map((a) => ({
...a,
visible: a.visible !== false,
})),
})),
usageNotes: m.usageNotes || '',
}
}
export function publicBrandManifest(manifest) {
if (!manifest.published) {
return { published: false, organization: manifest.organization, message: 'Brand kit is temporarily unavailable.' }
}
return {
...manifest,
groups: manifest.groups
.filter((g) => g.visible !== false)
.map((g) => ({
...g,
assets: g.assets.filter((a) => a.visible !== false),
})),
}
}
export function listBrandFiles(dataDir) {
const dir = brandDir(dataDir)
if (!fs.existsSync(dir)) return []
return fs.readdirSync(dir).filter((f) => !f.startsWith('.'))
}
export function safeBrandFilename(name) {
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '-')
}
export async function rebuildBrandZip(dataDir) {
const dir = brandDir(dataDir)
const zipPath = path.join(dir, 'MIM4U-Brand-Kit.zip')
const files = listBrandFiles(dataDir).filter((f) => f !== 'MIM4U-Brand-Kit.zip' && !f.endsWith('.json'))
if (!files.length) return { ok: false, error: 'No brand files to zip' }
const { spawnSync } = await import('node:child_process')
const args = ['-q', 'MIM4U-Brand-Kit.zip', ...files]
const result = spawnSync('zip', args, { cwd: dir })
if (result.status !== 0) {
return { ok: false, error: result.stderr?.toString() || 'zip failed' }
}
return { ok: true, path: '/api/public/brand/files/MIM4U-Brand-Kit.zip', fileCount: files.length }
}
+47
View File
@@ -0,0 +1,47 @@
import fs from 'node:fs'
import path from 'node:path'
export function ensureDataDir(dataDir) {
fs.mkdirSync(dataDir, { recursive: true })
}
export function appendJsonl(dataDir, file, record) {
const line = `${JSON.stringify({ ...record, ts: new Date().toISOString() })}\n`
fs.appendFileSync(path.join(dataDir, file), line, 'utf8')
}
export function readJsonl(dataDir, file, { limit = 100, reverse = true } = {}) {
const filePath = path.join(dataDir, file)
if (!fs.existsSync(filePath)) return []
const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean)
const parsed = lines.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
}).filter(Boolean)
const ordered = reverse ? parsed.reverse() : parsed
return ordered.slice(0, limit)
}
export function readJsonFile(dataDir, file, fallback) {
const filePath = path.join(dataDir, file)
if (!fs.existsSync(filePath)) return fallback
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch {
return fallback
}
}
export function writeJsonFile(dataDir, file, data) {
const filePath = path.join(dataDir, file)
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
}
export function rewriteJsonl(dataDir, file, records) {
const filePath = path.join(dataDir, file)
const content = records.map((r) => JSON.stringify(r)).join('\n')
fs.writeFileSync(filePath, content ? `${content}\n` : '', 'utf8')
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Shared MIM4U QR brand spec — used by QR Code Monkey and QRCG providers.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const BRAND_SPEC_PATH = path.join(__dirname, '../defaults/qrcg-mim-brand.json')
let cachedBrandSpec = null
export function loadBrandSpec() {
if (cachedBrandSpec) return cachedBrandSpec
try {
cachedBrandSpec = JSON.parse(fs.readFileSync(BRAND_SPEC_PATH, 'utf8'))
} catch {
cachedBrandSpec = {
colors: { background: '#1a3c34', foreground: '#c5a059' },
referenceImage: '/brand/qr-code-branded-reference.png',
qrcodeMonkey: {
body: 'diamond',
eye: 'frame14',
eyeBall: 'ball16',
bodyColor: '#c5a059',
bgColor: '#1a3c34',
eye1Color: '#c5a059',
eye2Color: '#c5a059',
eye3Color: '#c5a059',
eyeBall1Color: '#c5a059',
eyeBall2Color: '#c5a059',
eyeBall3Color: '#c5a059',
logoMode: 'clean',
},
}
}
return cachedBrandSpec
}
export const QR_PRESETS = {
donate: {
title: 'MIM4U — Donate',
url: 'https://mim4u.org/donate',
purpose: 'donate',
},
brand: {
title: 'MIM4U — Brand assets',
url: 'https://mim4u.org/brand',
purpose: 'brand',
},
home: {
title: 'MIM4U — Home',
url: 'https://mim4u.org',
purpose: 'home',
},
}
export function mimQrBrandSpec(publicUrl) {
const spec = loadBrandSpec()
const siteBase = String(publicUrl || process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '')
const logoUrl =
process.env.QR_MONKEY_LOGO_URL?.trim() ||
process.env.MIM_QR_LOGO_URL?.trim() ||
`${siteBase}/brand/logo-square.png`
return {
colors: spec.colors,
referenceImage: spec.referenceImage,
qrcodeMonkey: { ...spec.qrcodeMonkey, logo: logoUrl },
logoUrl,
}
}
+141
View File
@@ -0,0 +1,141 @@
/**
* QR Code Monkey API — custom QR with logo and design
* @see https://www.qrcode-monkey.com/qr-code-api-with-logo/
*/
import { loadBrandSpec, mimQrBrandSpec } from './qr-brand.js'
const DIRECT_BASE = 'https://api.qrcode-monkey.com'
const RAPIDAPI_BASE = 'https://qrcode-monkey.p.rapidapi.com'
const RAPIDAPI_HOST = 'qrcode-monkey.p.rapidapi.com'
export function getQrMonkeyBaseUrl({ preferRapidApi } = {}) {
const custom = String(process.env.QR_MONKEY_API_BASE || '').trim()
if (custom) return custom.replace(/\/$/, '')
const useRapid =
preferRapidApi ||
['1', 'true', 'yes'].includes(String(process.env.QR_MONKEY_USE_RAPIDAPI || '').toLowerCase())
if (useRapid && getRapidApiKey()) return RAPIDAPI_BASE
return DIRECT_BASE
}
export function hasRapidApiKey() {
return Boolean(getRapidApiKey())
}
export function getRapidApiKey() {
return String(process.env.RAPIDAPI_KEY || process.env.QR_MONKEY_RAPIDAPI_KEY || '').trim()
}
export function isQrMonkeyConfigured() {
return true
}
export function defaultMonkeyConfig(publicUrl) {
const spec = loadBrandSpec()
const brand = mimQrBrandSpec(publicUrl)
const monkey = { ...spec.qrcodeMonkey }
if (brand.logoUrl) monkey.logo = brand.logoUrl
const uploaded = String(process.env.QR_MONKEY_LOGO_FILE || '').trim()
if (uploaded) monkey.logo = uploaded
return monkey
}
function monkeyHeaders(useRapidApi) {
const headers = { 'Content-Type': 'application/json', Accept: 'image/*' }
if (useRapidApi && getRapidApiKey()) {
headers['x-rapidapi-key'] = getRapidApiKey()
headers['x-rapidapi-host'] = RAPIDAPI_HOST
}
return headers
}
function rapidSubscriptionError(text) {
const msg = String(text || '').toLowerCase()
return msg.includes('not subscribed') || msg.includes('subscription')
}
async function postMonkeyCustom(baseUrl, useRapidApi, payload) {
const res = await fetch(`${baseUrl}/qr/custom`, {
method: 'POST',
headers: monkeyHeaders(useRapidApi),
body: JSON.stringify(payload),
})
const contentType = res.headers.get('content-type') || ''
if (!res.ok) {
const text = await res.text().catch(() => '')
return { res, text, buffer: null, contentType }
}
const buffer = Buffer.from(await res.arrayBuffer())
return { res, text: '', buffer, contentType }
}
export async function generateMonkeyQr({
data,
config,
size,
file = 'png',
publicUrl,
}) {
const payload = {
data,
config: config || defaultMonkeyConfig(publicUrl),
size: Number(size || process.env.QR_MONKEY_SIZE || 600),
download: false,
file,
}
const useRapid = ['1', 'true', 'yes'].includes(String(process.env.QR_MONKEY_USE_RAPIDAPI || '').toLowerCase())
let baseUrl = getQrMonkeyBaseUrl({ preferRapidApi: useRapid })
let attempt = await postMonkeyCustom(baseUrl, baseUrl === RAPIDAPI_BASE, payload)
if (
!attempt.res.ok &&
baseUrl === RAPIDAPI_BASE &&
(attempt.res.status === 403 || attempt.res.status === 401) &&
rapidSubscriptionError(attempt.text)
) {
baseUrl = DIRECT_BASE
attempt = await postMonkeyCustom(baseUrl, false, payload)
}
if (!attempt.res.ok) {
const err = new Error(
`QR Code Monkey error (${attempt.res.status})${attempt.text ? `: ${attempt.text.slice(0, 200)}` : ''}`,
)
err.status = attempt.res.status
throw err
}
return {
buffer: attempt.buffer,
contentType: attempt.contentType || 'image/png',
file,
apiBase: baseUrl,
}
}
export async function uploadMonkeyLogo(buffer, filename, mimeType = 'image/png') {
const form = new FormData()
form.append('file', new Blob([buffer], { type: mimeType }), filename)
const headers = {}
const rapidKey = getRapidApiKey()
if (rapidKey) {
headers['x-rapidapi-key'] = rapidKey
headers['x-rapidapi-host'] = RAPIDAPI_HOST
}
const res = await fetch(`${getQrMonkeyBaseUrl()}/qr/uploadImage`, {
method: 'POST',
headers,
body: form,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data?.message || `QR Code Monkey upload failed (${res.status})`)
err.status = res.status
throw err
}
return data.file
}
+284
View File
@@ -0,0 +1,284 @@
/**
* QR provider orchestration — QR Code Monkey (default) or QRCG (dynamic tracking).
*/
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { appendJsonl, readJsonl, rewriteJsonl } from './data.js'
import { QR_PRESETS, mimQrBrandSpec } from './qr-brand.js'
import { defaultMonkeyConfig, generateMonkeyQr, getQrMonkeyBaseUrl, hasRapidApiKey, isQrMonkeyConfigured } from './qr-monkey.js'
import {
createQrcode as createQrcgQrcode,
defaultMimCustomizations,
fetchQrcgAccount,
getQrcode as getQrcgQrcode,
getQrcodeScanTotals,
isQrcgConfigured,
listQrcodes as listQrcgQrcodes,
updateQrcode as updateQrcgQrcode,
} from './qrcg.js'
export { QR_PRESETS, mimQrBrandSpec, defaultMimCustomizations }
export function getQrProvider() {
const forced = String(process.env.MIM_QR_PROVIDER || '').trim().toLowerCase()
if (forced === 'qrcg' && isQrcgConfigured()) return 'qrcg'
if (forced === 'qrcode-monkey' || forced === 'monkey') return 'qrcode-monkey'
if (isQrcgConfigured() && forced !== 'qrcode-monkey' && forced !== 'monkey') {
return isQrMonkeyConfigured() ? 'qrcode-monkey' : 'qrcg'
}
return 'qrcode-monkey'
}
export function qrCodesDir(dataDir) {
const dir = path.join(dataDir, 'qr-codes')
fs.mkdirSync(dir, { recursive: true })
return dir
}
function mapLocalRow(row, publicApiBase, publicSiteBase) {
const publicPath = `/api/public/qr/${row.id}`
const imagePath = row.imageFile ? publicPath : null
const site = String(publicSiteBase || '').replace(/\/$/, '')
return {
id: row.id,
provider: row.provider || 'qrcode-monkey',
type: 'url',
title: row.title,
status: row.status || 'active',
url: row.url,
shortUrl: row.shortUrl || null,
previewUrl: imagePath ? (site ? `${site}${imagePath}` : imagePath) : null,
imageUrl: imagePath,
createdAt: row.ts,
purpose: row.purpose || null,
scans: row.scans || null,
}
}
export async function getQrStatus(publicUrl) {
const provider = getQrProvider()
const brand = mimQrBrandSpec(publicUrl)
if (provider === 'qrcode-monkey') {
const rapidKey = hasRapidApiKey()
const useRapid = ['1', 'true', 'yes'].includes(String(process.env.QR_MONKEY_USE_RAPIDAPI || '').toLowerCase())
return {
provider,
configured: true,
dynamicTracking: false,
apiBase: getQrMonkeyBaseUrl({ preferRapidApi: useRapid }),
rapidApiKeySet: rapidKey,
rapidApiSubscribed: useRapid && rapidKey,
rapidApiNote: rapidKey && !useRapid
? 'RAPIDAPI_KEY is set; using direct API until you subscribe at rapidapi.com and set QR_MONKEY_USE_RAPIDAPI=1'
: undefined,
presets: Object.keys(QR_PRESETS),
brand,
docsUrl: 'https://www.qrcode-monkey.com/qr-code-api-with-logo/',
}
}
if (!isQrcgConfigured()) {
return {
provider: 'qrcg',
configured: false,
dynamicTracking: true,
presets: Object.keys(QR_PRESETS),
brand,
docsUrl: 'https://dev.qrcg.com/',
}
}
try {
const account = await fetchQrcgAccount()
return {
provider: 'qrcg',
configured: true,
dynamicTracking: true,
account,
presets: Object.keys(QR_PRESETS),
brand,
docsUrl: 'https://dev.qrcg.com/',
}
} catch (e) {
return {
provider: 'qrcg',
configured: true,
dynamicTracking: true,
accountError: e.message,
presets: Object.keys(QR_PRESETS),
brand,
docsUrl: 'https://dev.qrcg.com/',
}
}
}
export async function listQrcodes(dataDir, { cursor, limit = 20, publicApiBase, publicSiteBase } = {}) {
const provider = getQrProvider()
const local = readJsonl(dataDir, 'qr-codes.jsonl', { limit: 500 })
if (provider === 'qrcode-monkey') {
const rows = local
.filter((r) => !r.provider || r.provider === 'qrcode-monkey')
.slice(0, limit)
.map((r) => mapLocalRow(r, publicApiBase, publicSiteBase))
return { data: rows, pagination: { hasMore: false } }
}
const remote = await listQrcgQrcodes({ cursor, limit })
const localById = Object.fromEntries(local.map((r) => [String(r.qrcodeId || r.id), r]))
const data = (remote.data || []).map((row) => ({
id: row.id,
provider: 'qrcg',
type: row.type,
title: row.title,
status: row.status,
url: row.url,
shortUrl: row.shortUrl,
previewUrl: row.previewUrl,
createdAt: row.createdAt,
purpose: localById[String(row.id)]?.purpose || null,
scans: row.scans,
}))
return { ...remote, data }
}
export async function createQrcode(dataDir, { url, title, purpose, preset, customizations, publicUrl, publicApiBase, publicSiteBase, createdBy }) {
const provider = getQrProvider()
if (provider === 'qrcode-monkey') {
const id = `qr_${crypto.randomUUID()}`
const { buffer, contentType } = await generateMonkeyQr({
data: url,
config: customizations || defaultMonkeyConfig(publicUrl),
publicUrl,
})
const ext = contentType.includes('svg') ? 'svg' : 'png'
const imageFile = `${id}.${ext}`
fs.writeFileSync(path.join(qrCodesDir(dataDir), imageFile), buffer)
const record = {
id,
provider: 'qrcode-monkey',
qrcodeId: id,
url,
title,
purpose: purpose || null,
preset: preset || null,
imageFile,
status: 'active',
createdBy,
ts: new Date().toISOString(),
}
appendJsonl(dataDir, 'qr-codes.jsonl', record)
return mapLocalRow(record, publicApiBase, publicSiteBase || publicUrl)
}
const created = await createQrcgQrcode({
url,
title,
customizations,
})
appendJsonl(dataDir, 'qr-codes.jsonl', {
qrcodeId: created.id,
provider: 'qrcg',
purpose: purpose || null,
preset: preset || null,
url,
title,
createdBy,
ts: new Date().toISOString(),
})
return {
id: created.id,
provider: 'qrcg',
type: created.type,
title: created.title,
status: created.status,
url: created.url,
shortUrl: created.shortUrl,
previewUrl: created.previewUrl,
createdAt: created.createdAt,
scans: created.scans,
}
}
export function getLocalQrcode(dataDir, id) {
const rows = readJsonl(dataDir, 'qr-codes.jsonl', { limit: 10000, reverse: false })
return rows.find((r) => r.id === id || r.qrcodeId === id) || null
}
export function getQrcodeImagePath(dataDir, id) {
const row = getLocalQrcode(dataDir, id)
if (!row?.imageFile) return null
const filePath = path.join(qrCodesDir(dataDir), row.imageFile)
return fs.existsSync(filePath) ? filePath : null
}
export async function getQrcode(dataDir, id, publicApiBase, publicSiteBase) {
const local = getLocalQrcode(dataDir, id)
if (local?.provider === 'qrcode-monkey' || local?.imageFile) {
return mapLocalRow(local, publicApiBase, publicSiteBase)
}
if (getQrProvider() === 'qrcg') {
const row = await getQrcgQrcode(id)
return {
id: row.id,
provider: 'qrcg',
title: row.title,
status: row.status,
url: row.url,
shortUrl: row.shortUrl,
previewUrl: row.previewUrl,
createdAt: row.createdAt,
scans: row.scans,
}
}
return null
}
export async function updateQrcode(dataDir, id, patch, publicApiBase, publicSiteBase) {
const local = getLocalQrcode(dataDir, id)
if (local?.provider === 'qrcode-monkey') {
const rows = readJsonl(dataDir, 'qr-codes.jsonl', { limit: 10000, reverse: false })
const idx = rows.findIndex((r) => r.id === id)
if (idx < 0) {
const err = new Error('QR code not found')
err.status = 404
throw err
}
if (patch.status) rows[idx].status = patch.status
if (patch.title) rows[idx].title = patch.title
if (patch.url) rows[idx].url = patch.url
rows[idx].updatedAt = new Date().toISOString()
rewriteJsonl(dataDir, 'qr-codes.jsonl', rows)
return mapLocalRow(rows[idx], publicApiBase, publicSiteBase)
}
const updated = await updateQrcgQrcode(id, patch)
return {
id: updated.id,
provider: 'qrcg',
title: updated.title,
status: updated.status,
url: updated.url,
shortUrl: updated.shortUrl,
previewUrl: updated.previewUrl,
createdAt: updated.createdAt,
scans: updated.scans,
}
}
export async function getQrcodeScans(id) {
if (getQrProvider() !== 'qrcg') {
const err = new Error('Scan analytics require QRCG dynamic QR provider')
err.status = 400
throw err
}
return getQrcodeScanTotals(id)
}
export function isAnyQrConfigured() {
return isQrMonkeyConfigured() || isQrcgConfigured()
}
+109
View File
@@ -0,0 +1,109 @@
/**
* QRCG by Bitly — Public API (preview) — dynamic QR with scan analytics
* @see https://dev.qrcg.com/
*/
import { loadBrandSpec } from './qr-brand.js'
const DEFAULT_BASE = 'https://dev.qrcg.com/v3-preview'
export function getQrcgApiKey() {
return String(process.env.QRCG_API_KEY || '').trim()
}
export function getQrcgBaseUrl() {
return String(process.env.QRCG_API_BASE_URL || DEFAULT_BASE).replace(/\/$/, '')
}
export function isQrcgConfigured() {
return Boolean(getQrcgApiKey())
}
export function getQrcgLogoId() {
const raw = String(process.env.QRCG_LOGO_ID || '').trim()
if (!raw) return null
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : null
}
/** Branded QR styling for QRCG API. */
export function defaultMimCustomizations() {
const spec = loadBrandSpec()
const customizations = structuredClone(spec.customizations)
const logoId = getQrcgLogoId()
if (logoId) customizations.logo = { id: logoId }
return customizations
}
async function parseResponse(res) {
const text = await res.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return { raw: text }
}
}
export async function qrcgRequest(method, path, body) {
const key = getQrcgApiKey()
if (!key) {
const err = new Error('QRCG API key not configured (set QRCG_API_KEY)')
err.status = 503
throw err
}
const url = `${getQrcgBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`
const res = await fetch(url, {
method,
headers: {
Authorization: `Key ${key}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const data = await parseResponse(res)
if (!res.ok) {
const err = new Error(data?.message || data?.error || `QRCG API error (${res.status})`)
err.status = res.status
err.body = data
throw err
}
return data
}
export async function fetchQrcgAccount() {
return qrcgRequest('GET', '/user/me')
}
export async function listQrcodes({ cursor, limit = 20 } = {}) {
const params = new URLSearchParams()
if (cursor) params.set('cursor', cursor)
if (limit) params.set('limit', String(limit))
const qs = params.toString()
return qrcgRequest('GET', `/qrcodes${qs ? `?${qs}` : ''}`)
}
export async function getQrcode(id) {
return qrcgRequest('GET', `/qrcodes/${encodeURIComponent(id)}`)
}
export async function createQrcode({ url, title, status = 'active', customizations, folderId }) {
return qrcgRequest('POST', '/qrcodes', {
type: 'url',
url,
title,
status,
customizations: customizations || defaultMimCustomizations(),
...(folderId != null ? { folderId } : {}),
})
}
export async function updateQrcode(id, patch) {
return qrcgRequest('PATCH', `/qrcodes/${encodeURIComponent(id)}`, patch)
}
export async function getQrcodeScanTotals(id) {
return qrcgRequest('GET', `/qrcodes/${encodeURIComponent(id)}/scans/total`)
}
+84
View File
@@ -0,0 +1,84 @@
import { maskSecret } from './auth.js'
import { readJsonFile, writeJsonFile } from './data.js'
const SETTINGS_FILE = 'admin-settings.json'
const DEFAULT_SETTINGS = {
donationsEnabled: true,
stripePublishableKey: '',
stripeSecretKey: '',
stripeWebhookSecret: '',
updatedAt: null,
updatedBy: null,
}
function envFallback() {
return {
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || process.env.VITE_STRIPE_PUBLISHABLE_KEY || '',
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
}
}
export function loadSettings(dataDir) {
const stored = readJsonFile(dataDir, SETTINGS_FILE, {})
const env = envFallback()
return {
...DEFAULT_SETTINGS,
donationsEnabled: stored.donationsEnabled ?? DEFAULT_SETTINGS.donationsEnabled,
stripePublishableKey: stored.stripePublishableKey || env.stripePublishableKey,
stripeSecretKey: stored.stripeSecretKey || env.stripeSecretKey,
stripeWebhookSecret: stored.stripeWebhookSecret || env.stripeWebhookSecret,
updatedAt: stored.updatedAt || null,
updatedBy: stored.updatedBy || null,
}
}
export function saveSettings(dataDir, patch, updatedBy) {
const current = loadSettings(dataDir)
const next = { ...current }
if (typeof patch.donationsEnabled === 'boolean') next.donationsEnabled = patch.donationsEnabled
for (const key of ['stripePublishableKey', 'stripeSecretKey', 'stripeWebhookSecret']) {
const value = patch[key]
if (typeof value === 'string' && value.trim() && !value.includes('…')) {
next[key] = value.trim()
}
}
next.updatedAt = new Date().toISOString()
next.updatedBy = updatedBy || null
writeJsonFile(dataDir, SETTINGS_FILE, {
donationsEnabled: next.donationsEnabled,
stripePublishableKey: next.stripePublishableKey,
stripeSecretKey: next.stripeSecretKey,
stripeWebhookSecret: next.stripeWebhookSecret,
updatedAt: next.updatedAt,
updatedBy: next.updatedBy,
})
return next
}
export function publicConfig(settings) {
const stripeReady = Boolean(settings.donationsEnabled && settings.stripePublishableKey && settings.stripeSecretKey)
return {
donationsEnabled: Boolean(settings.donationsEnabled),
stripeConfigured: stripeReady,
stripePublishableKey: stripeReady ? settings.stripePublishableKey : '',
}
}
export function adminSettingsView(settings) {
return {
donationsEnabled: settings.donationsEnabled,
stripePublishableKey: settings.stripePublishableKey,
stripeSecretKey: maskSecret(settings.stripeSecretKey),
stripeWebhookSecret: maskSecret(settings.stripeWebhookSecret),
stripeConfigured: Boolean(settings.stripeSecretKey && settings.stripePublishableKey),
updatedAt: settings.updatedAt,
updatedBy: settings.updatedBy,
}
}
+45
View File
@@ -0,0 +1,45 @@
import Stripe from 'stripe'
import { loadSettings } from './settings.js'
export function getStripeClient(dataDir) {
const settings = loadSettings(dataDir)
if (!settings.stripeSecretKey) return null
return new Stripe(settings.stripeSecretKey)
}
export function isStripeLive(dataDir) {
const settings = loadSettings(dataDir)
return Boolean(settings.donationsEnabled && settings.stripeSecretKey && settings.stripePublishableKey)
}
export async function fetchStripeAccountStatus(dataDir) {
const stripe = getStripeClient(dataDir)
if (!stripe) {
return { configured: false, livemode: false, balance: null, recentCharges: [] }
}
const [balance, charges] = await Promise.all([
stripe.balance.retrieve().catch(() => null),
stripe.charges.list({ limit: 10 }).catch(() => ({ data: [] })),
])
return {
configured: true,
livemode: Boolean(charges.data[0]?.livemode),
balance: balance
? {
available: balance.available,
pending: balance.pending,
}
: null,
recentCharges: (charges.data || []).map((c) => ({
id: c.id,
amount: c.amount,
currency: c.currency,
status: c.status,
created: c.created,
receiptEmail: c.receipt_email || c.billing_details?.email || null,
description: c.description,
})),
}
}
+85
View File
@@ -0,0 +1,85 @@
import { hashPassword, verifyPassword } from './auth.js'
import { readJsonFile, writeJsonFile } from './data.js'
const USERS_FILE = 'users.json'
const DEFAULT_USERS = [
{
id: 'admin-1',
email: '[email protected]',
role: 'admin',
name: 'MIM Administrator',
permissions: ['all'],
passwordEnv: 'MIM_ADMIN_PASSWORD',
defaultPassword: 'demo123',
},
{
id: 'volunteer-1',
email: '[email protected]',
role: 'volunteer',
name: 'MIM Volunteer',
permissions: ['view_requests', 'update_assignments'],
passwordEnv: 'MIM_VOLUNTEER_PASSWORD',
defaultPassword: 'demo123',
},
{
id: 'resource-1',
email: '[email protected]',
role: 'resource',
name: 'MIM Resource Partner',
permissions: ['submit_requests', 'view_own_requests'],
passwordEnv: 'MIM_RESOURCE_PASSWORD',
defaultPassword: 'demo123',
},
]
function sanitizeUser(user) {
const { passwordHash, ...rest } = user
return rest
}
export function ensureUsers(dataDir) {
let users = readJsonFile(dataDir, USERS_FILE, null)
if (!users?.length) {
const isProd = process.env.NODE_ENV === 'production'
users = DEFAULT_USERS.map((seed) => {
const envPassword = process.env[seed.passwordEnv]
const password = envPassword || (!isProd ? seed.defaultPassword : null)
if (!password) {
console.warn(`[mim-api] No password for ${seed.email} — set ${seed.passwordEnv}`)
return null
}
return {
id: seed.id,
email: seed.email,
role: seed.role,
name: seed.name,
permissions: seed.permissions,
passwordHash: hashPassword(password),
createdAt: new Date().toISOString(),
}
}).filter(Boolean)
if (users.length) writeJsonFile(dataDir, USERS_FILE, users)
}
return users || []
}
export function findUserByEmail(dataDir, email) {
const users = ensureUsers(dataDir)
return users.find((u) => u.email.toLowerCase() === email.toLowerCase()) || null
}
export function authenticateUser(dataDir, email, password) {
const user = findUserByEmail(dataDir, email)
if (!user || !verifyPassword(password, user.passwordHash)) return null
return sanitizeUser({ ...user, lastLogin: new Date().toISOString() })
}
export function listUsers(dataDir) {
return ensureUsers(dataDir).map(sanitizeUser)
}
export function getUserById(dataDir, id) {
const user = ensureUsers(dataDir).find((u) => u.id === id)
return user ? sanitizeUser(user) : null
}
+31
View File
@@ -0,0 +1,31 @@
import { verifyToken } from '../lib/auth.js'
import { getUserById } from '../lib/users.js'
export function createAuthMiddleware({ dataDir, authSecret }) {
return function authMiddleware(req, res, next) {
const header = req.headers.authorization || ''
const token = header.startsWith('Bearer ') ? header.slice(7) : null
if (!token) {
return res.status(401).json({ error: 'Authentication required' })
}
const payload = verifyToken(token, authSecret)
if (!payload?.sub) {
return res.status(401).json({ error: 'Invalid or expired session' })
}
const user = getUserById(dataDir, payload.sub)
if (!user) {
return res.status(401).json({ error: 'User not found' })
}
req.user = user
next()
}
}
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
next()
}
}
+12
View File
@@ -0,0 +1,12 @@
export function createCorsMiddleware(corsOrigins) {
return function cors(req, res, next) {
const origin = req.headers.origin
if (origin && (corsOrigins.includes(origin) || corsOrigins.includes('*'))) {
res.setHeader('Access-Control-Allow-Origin', origin)
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Stripe-Signature')
if (req.method === 'OPTIONS') return res.sendStatus(204)
next()
}
}
+429
View File
@@ -0,0 +1,429 @@
import express from 'express'
import { readJsonl, appendJsonl, rewriteJsonl } from '../lib/data.js'
import { adminSettingsView, loadSettings, publicConfig, saveSettings } from '../lib/settings.js'
import { fetchStripeAccountStatus, getStripeClient, isStripeLive } from '../lib/stripe-service.js'
import { listUsers } from '../lib/users.js'
import { createAuthMiddleware, requireRole } from '../middleware/auth.js'
function priorityLabel(requestType) {
const urgent = ['emergency', 'crisis', 'urgent']
const type = String(requestType || '').toLowerCase()
if (urgent.some((u) => type.includes(u))) return 'High'
if (type.includes('clothing') || type.includes('food')) return 'Medium'
return 'Low'
}
function timeAgo(iso) {
if (!iso) return 'unknown'
const diff = Date.now() - new Date(iso).getTime()
const hours = Math.floor(diff / 3600000)
if (hours < 1) return 'just now'
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
const days = Math.floor(hours / 24)
return `${days} day${days === 1 ? '' : 's'} ago`
}
export function createAdminRoutes({ dataDir, authSecret }) {
const router = express.Router()
const auth = createAuthMiddleware({ dataDir, authSecret })
router.use(auth)
router.use(requireRole('admin'))
router.get('/dashboard', (_req, res) => {
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 500 })
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 500 })
const assignments = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 500 })
const pending = assistance.filter((r) => !r.status || r.status === 'pending')
const today = new Date().toISOString().slice(0, 10)
const deliveriesToday = assignments.filter((a) => a.scheduledDate?.startsWith(today)).length
const monthlyDonations = donations.reduce((sum, d) => sum + (d.amountTotal || 0), 0)
res.json({
pendingRequests: pending.length,
activeVolunteers: assignments.filter((a) => a.status === 'active').length || listUsers(dataDir).filter((u) => u.role === 'volunteer').length,
deliveriesToday,
monthlyBudget: 15000,
monthlySpent: Math.round(monthlyDonations / 100),
monthlyDonationCents: monthlyDonations,
donationCount: donations.length,
})
})
router.get('/assistance-requests', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200)
const status = req.query.status
let rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 500 })
if (status) rows = rows.filter((r) => (r.status || 'pending') === status)
res.json({
data: rows.slice(0, limit).map((r) => ({
id: r.id,
requestType: r.requestType,
student: `${r.studentInfo?.firstName || ''} ${r.studentInfo?.lastName || ''}`.trim(),
school: r.studentInfo?.school,
need: r.details?.slice(0, 120) || r.requestType,
priority: priorityLabel(r.requestType),
status: r.status || 'pending',
contactName: r.contactInfo?.parentName,
contactEmail: r.contactInfo?.email,
contactPhone: r.contactInfo?.phone,
timeAgo: timeAgo(r.ts),
ts: r.ts,
})),
total: rows.length,
})
})
router.patch('/assistance-requests/:id', express.json({ limit: '32kb' }), (req, res) => {
const { id } = req.params
const rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 10000, reverse: false })
const idx = rows.findIndex((r) => r.id === id)
if (idx < 0) return res.status(404).json({ error: 'Request not found' })
const patch = req.body || {}
rows[idx] = {
...rows[idx],
status: patch.status || rows[idx].status || 'pending',
assignedTo: patch.assignedTo ?? rows[idx].assignedTo,
adminNotes: patch.adminNotes ?? rows[idx].adminNotes,
updatedAt: new Date().toISOString(),
updatedBy: req.user.email,
}
rewriteJsonl(dataDir, 'assistance-requests.jsonl', rows)
res.json({ ok: true, request: rows[idx] })
})
router.get('/donations', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200)
const rows = readJsonl(dataDir, 'donations.jsonl', { limit })
res.json({
data: rows.map((d) => ({
id: d.sessionId || d.id,
amountCents: d.amountTotal || 0,
amountUsd: ((d.amountTotal || 0) / 100).toFixed(2),
email: d.customerEmail || d.metadata?.donorEmail,
donorName: d.metadata?.donorName,
anonymous: d.metadata?.anonymous === 'true',
ts: d.ts,
timeAgo: timeAgo(d.ts),
})),
total: rows.length,
})
})
router.get('/analytics/summary', (_req, res) => {
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 1000 })
const events = readJsonl(dataDir, 'events.jsonl', { limit: 5000 })
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 1000 })
const donationAmount = donations.reduce((s, d) => s + (d.amountTotal || 0), 0) / 100
const donationEvents = events.filter((e) => String(e.event || e.eventName || '').includes('donation'))
const pageViews = aggregatePageViews(events)
res.json({
donationMetrics: {
amount: Math.round(donationAmount),
count: donations.length,
recurring: donations.filter((d) => d.metadata?.recurring === 'true').length,
},
pageViews,
userEngagement: {
sessions: events.filter((e) => e.event === 'page_view' || e.eventName === 'page_view').length || events.length,
avgDuration: 185,
bounceRate: 0.34,
},
conversionRates: {
donation: donations.length / Math.max(pageViews.find((p) => p.page === 'Donate')?.views || 1, 1),
volunteer: assistance.length / Math.max(events.length, 1),
contact: readJsonl(dataDir, 'contact.jsonl', { limit: 100 }).length / Math.max(events.length, 1),
},
familiesHelped: assistance.filter((a) => a.status === 'completed').length,
activeVolunteers: listUsers(dataDir).filter((u) => u.role === 'volunteer').length,
})
})
router.get('/analytics/activity', (_req, res) => {
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 15 })
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 15 })
const events = readJsonl(dataDir, 'events.jsonl', { limit: 15 })
const feed = [
...donations.map((d) => ({
type: 'donation',
title: `Donation received — $${((d.amountTotal || 0) / 100).toFixed(2)}`,
detail: d.customerEmail || 'Anonymous donor',
ts: d.ts,
timeAgo: timeAgo(d.ts),
})),
...assistance.map((a) => ({
type: 'assistance',
title: `Assistance request — ${a.requestType}`,
detail: `${a.studentInfo?.firstName || ''} ${a.studentInfo?.lastName || ''}`.trim(),
ts: a.ts,
timeAgo: timeAgo(a.ts),
})),
...events.map((e) => ({
type: 'event',
title: String(e.event || e.eventName || 'Site event'),
detail: e.page || e.path || '',
ts: e.ts,
timeAgo: timeAgo(e.ts),
})),
]
.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())
.slice(0, 30)
res.json({ feed })
})
router.get('/analytics/advanced', (_req, res) => {
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 2000 })
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 2000 })
const monthly = buildMonthlySeries(donations, assistance)
res.json({
impactMetrics: monthly,
predictions: {
nextMonthDonations: monthly.length ? Math.round(monthly[monthly.length - 1].donations * 1.08) : 0,
studentsServedTrend: monthly.length ? monthly[monthly.length - 1].studentsServed : 0,
},
geographic: aggregateSchools(assistance),
})
})
router.get('/feeds/recent', (_req, res) => {
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 20 })
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 20 })
res.json({
donations: donations.map((d) => ({
id: d.sessionId,
amountUsd: ((d.amountTotal || 0) / 100).toFixed(2),
email: d.customerEmail,
ts: d.ts,
timeAgo: timeAgo(d.ts),
})),
assistance: assistance.map((a) => ({
id: a.id,
type: a.requestType,
student: `${a.studentInfo?.firstName || ''} ${a.studentInfo?.lastName || ''}`.trim(),
school: a.studentInfo?.school,
status: a.status || 'pending',
ts: a.ts,
timeAgo: timeAgo(a.ts),
})),
generatedAt: new Date().toISOString(),
})
})
router.get('/settings', (_req, res) => {
res.json(adminSettingsView(loadSettings(dataDir)))
})
router.patch('/settings', express.json({ limit: '32kb' }), (req, res) => {
const next = saveSettings(dataDir, req.body || {}, req.user.email)
res.json(adminSettingsView(next))
})
router.get('/stripe/status', async (_req, res) => {
try {
const status = await fetchStripeAccountStatus(dataDir)
const settings = loadSettings(dataDir)
res.json({
...status,
donationsEnabled: settings.donationsEnabled,
publishableKeySet: Boolean(settings.stripePublishableKey),
})
} catch (e) {
res.status(502).json({ error: e.message || 'Stripe status unavailable' })
}
})
router.get('/volunteers/assignments', (_req, res) => {
const rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 100 })
if (!rows.length) {
return res.json({ data: seedAssignmentsFromAssistance(dataDir), seeded: true })
}
res.json({ data: rows })
})
router.patch('/volunteers/assignments/:id', express.json({ limit: '16kb' }), (req, res) => {
const rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 10000, reverse: false })
const idx = rows.findIndex((r) => r.id === req.params.id)
if (idx < 0) return res.status(404).json({ error: 'Assignment not found' })
rows[idx] = { ...rows[idx], ...req.body, updatedAt: new Date().toISOString() }
rewriteJsonl(dataDir, 'volunteer-assignments.jsonl', rows)
res.json({ ok: true, assignment: rows[idx] })
})
router.get('/partners/requests', (_req, res) => {
const rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 100 })
res.json({
data: rows.map((r) => ({
id: r.id,
partner: r.studentInfo?.school || 'Partner org',
student: `${r.studentInfo?.firstName || ''} ${r.studentInfo?.lastName || ''}`.trim(),
status: r.status || 'pending',
submitted: timeAgo(r.ts),
type: r.requestType,
})),
})
})
router.get('/training/modules', (_req, res) => {
res.json({
modules: [
{ id: 'onboarding', title: 'Volunteer Onboarding', progress: 100, durationMin: 45 },
{ id: 'safety', title: 'Safety & Confidentiality', progress: 85, durationMin: 30 },
{ id: 'assistance', title: 'Assistance Intake Workflow', progress: 60, durationMin: 25 },
{ id: 'donor-care', title: 'Donor Stewardship Basics', progress: 40, durationMin: 20 },
],
})
})
return router
}
function aggregatePageViews(events) {
const counts = {}
for (const e of events) {
const page = e.page || e.path || e.properties?.page
if (!page) continue
const label = String(page).replace(/^\//, '').split('/')[0] || 'Home'
const key = label.charAt(0).toUpperCase() + label.slice(1)
counts[key] = (counts[key] || 0) + 1
}
if (!Object.keys(counts).length) {
return [
{ page: 'Home', views: 0, trend: 0 },
{ page: 'Donate', views: 0, trend: 0 },
]
}
return Object.entries(counts).map(([page, views]) => ({ page, views, trend: 0 }))
}
function buildMonthlySeries(donations, assistance) {
const buckets = {}
for (const d of donations) {
const month = (d.ts || '').slice(0, 7)
if (!month) continue
buckets[month] = buckets[month] || { month, donations: 0, studentsServed: 0, resourcesAllocated: 0, efficiency: 0.85 }
buckets[month].donations += (d.amountTotal || 0) / 100
}
for (const a of assistance) {
const month = (a.ts || '').slice(0, 7)
if (!month) continue
buckets[month] = buckets[month] || { month, donations: 0, studentsServed: 0, resourcesAllocated: 0, efficiency: 0.85 }
buckets[month].studentsServed += 1
buckets[month].resourcesAllocated += 1
}
return Object.values(buckets)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6)
.map((b) => ({ ...b, donations: Math.round(b.donations), efficiency: 0.85 + Math.min(b.studentsServed / 400, 0.1) }))
}
function aggregateSchools(assistance) {
const schools = {}
for (const a of assistance) {
const school = a.studentInfo?.school || 'Unknown'
schools[school] = (schools[school] || 0) + 1
}
return Object.entries(schools)
.map(([region, count]) => ({ region, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 8)
}
function seedAssignmentsFromAssistance(dataDir) {
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 5 })
return assistance.map((a, i) => ({
id: `asgn_${a.id || i}`,
student: `${a.studentInfo?.firstName || 'Student'} ${a.studentInfo?.lastName || ''}`.trim(),
items: a.requestType || 'Essentials kit',
school: a.studentInfo?.school || 'Los Angeles County',
deadline: i === 0 ? 'Tomorrow' : i === 1 ? 'Friday' : 'Next week',
status: 'pending',
scheduledDate: new Date().toISOString().slice(0, 10),
assistanceRequestId: a.id,
}))
}
export function createVolunteerRoutes({ dataDir, authSecret }) {
const router = express.Router()
const auth = createAuthMiddleware({ dataDir, authSecret })
router.use(auth)
router.use(requireRole('volunteer', 'admin'))
router.get('/assignments', (_req, res) => {
let rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 100 })
if (!rows.length) rows = seedAssignmentsFromAssistance(dataDir)
res.json({ data: rows })
})
router.patch('/assignments/:id', express.json({ limit: '16kb' }), (req, res) => {
let rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 10000, reverse: false })
if (!rows.length) rows = seedAssignmentsFromAssistance(dataDir)
const idx = rows.findIndex((r) => r.id === req.params.id)
if (idx < 0) return res.status(404).json({ error: 'Assignment not found' })
rows[idx] = { ...rows[idx], status: req.body?.status || rows[idx].status, updatedAt: new Date().toISOString() }
rewriteJsonl(dataDir, 'volunteer-assignments.jsonl', rows)
res.json({ ok: true, assignment: rows[idx] })
})
router.get('/schedule', (_req, res) => {
const assignments = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 50 })
const today = new Date()
res.json({
date: today.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }),
tasks: (assignments.length ? assignments : seedAssignmentsFromAssistance(dataDir)).slice(0, 5).map((a, i) => ({
id: a.id,
time: ['9:00 AM', '1:00 PM', '3:30 PM'][i % 3],
task: `Delivery — ${a.items || a.student}`,
location: a.school || 'Los Angeles County',
students: a.student ? 1 : null,
status: a.status || 'pending',
})),
})
})
router.get('/stats', (_req, res) => {
const completed = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 500 }).filter((a) => a.status === 'completed')
res.json({
familiesHelped: completed.length || readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 500 }).filter((a) => a.status === 'completed').length,
kitsAssembled: completed.length,
deliveries: completed.length,
hoursVolunteered: completed.length * 2,
})
})
return router
}
export function createResourceRoutes({ dataDir, authSecret }) {
const router = express.Router()
const auth = createAuthMiddleware({ dataDir, authSecret })
router.use(auth)
router.use(requireRole('resource', 'admin'))
router.get('/requests', (_req, res) => {
const rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 100 })
res.json({
data: rows.map((r) => ({
id: r.id,
title: `${r.studentInfo?.firstName || ''} ${r.studentInfo?.lastName || ''}`.trim(),
type: r.requestType,
status: r.status || 'pending',
submitted: timeAgo(r.ts),
school: r.studentInfo?.school,
})),
summary: {
pending: rows.filter((r) => !r.status || r.status === 'pending').length,
approved: rows.filter((r) => r.status === 'approved').length,
completed: rows.filter((r) => r.status === 'completed').length,
},
})
})
return router
}
export { isStripeLive, getStripeClient, publicConfig }
+34
View File
@@ -0,0 +1,34 @@
import express from 'express'
import { signToken, verifyToken } from '../lib/auth.js'
import { authenticateUser, getUserById } from '../lib/users.js'
export function createAuthRoutes({ dataDir, authSecret }) {
const router = express.Router()
router.post('/login', express.json({ limit: '16kb' }), (req, res) => {
const email = req.body?.email?.trim()
const password = req.body?.password
if (!email || !password) {
return res.status(400).json({ error: 'email and password required' })
}
const user = authenticateUser(dataDir, email, password)
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' })
}
const token = signToken({ sub: user.id, role: user.role, email: user.email }, authSecret)
res.json({ token, user })
})
router.get('/me', (req, res) => {
const header = req.headers.authorization || ''
const token = header.startsWith('Bearer ') ? header.slice(7) : null
if (!token) return res.status(401).json({ error: 'Authentication required' })
const payload = verifyToken(token, authSecret)
if (!payload?.sub) return res.status(401).json({ error: 'Invalid or expired session' })
const user = getUserById(dataDir, payload.sub)
if (!user) return res.status(401).json({ error: 'User not found' })
res.json({ user })
})
return router
}
+126
View File
@@ -0,0 +1,126 @@
import express from 'express'
import fs from 'node:fs'
import path from 'node:path'
import multer from 'multer'
import {
brandDir,
loadBrandManifest,
publicBrandManifest,
rebuildBrandZip,
safeBrandFilename,
saveBrandManifest,
} from '../lib/brand.js'
import { createAuthMiddleware, requireRole } from '../middleware/auth.js'
export function createBrandPublicRoutes({ dataDir }) {
const router = express.Router()
const filesDir = brandDir(dataDir)
fs.mkdirSync(filesDir, { recursive: true })
router.get('/brand', (_req, res) => {
res.json(publicBrandManifest(loadBrandManifest(dataDir)))
})
router.use('/brand/files', express.static(filesDir, {
maxAge: '1h',
setHeaders(res, filePath) {
if (filePath.endsWith('.svg')) res.setHeader('Content-Type', 'image/svg+xml')
},
}))
return router
}
export function createBrandAdminRoutes({ dataDir, authSecret }) {
const router = express.Router()
const filesDir = brandDir(dataDir)
fs.mkdirSync(filesDir, { recursive: true })
const auth = createAuthMiddleware({ dataDir, authSecret })
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, filesDir),
filename: (_req, file, cb) => cb(null, safeBrandFilename(file.originalname)),
})
const upload = multer({
storage,
limits: { fileSize: 25 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ok = /\.(png|jpe?g|webp|svg|ico|zip|pdf|md)$/i.test(file.originalname)
cb(ok ? null : new Error('File type not allowed'), ok)
},
})
router.use(auth)
router.use(requireRole('admin'))
router.get('/brand', (_req, res) => {
const manifest = loadBrandManifest(dataDir)
res.json({
manifest,
files: fs.existsSync(filesDir)
? fs.readdirSync(filesDir).map((name) => {
const stat = fs.statSync(path.join(filesDir, name))
return { name, size: stat.size, mtime: stat.mtime.toISOString() }
})
: [],
brandDir: filesDir,
})
})
router.put('/brand', express.json({ limit: '512kb' }), (req, res) => {
const next = saveBrandManifest(dataDir, req.body)
res.json({ ok: true, manifest: next })
})
router.patch('/brand', express.json({ limit: '128kb' }), (req, res) => {
const current = loadBrandManifest(dataDir)
const patch = req.body || {}
const next = saveBrandManifest(dataDir, {
...current,
...patch,
colors: patch.colors ?? current.colors,
typography: patch.typography ?? current.typography,
groups: patch.groups ?? current.groups,
kits: patch.kits ?? current.kits,
})
res.json({ ok: true, manifest: next })
})
router.post('/brand/upload', (req, res) => {
upload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message || 'Upload failed' })
if (!req.file) return res.status(400).json({ error: 'No file uploaded' })
const publicPath = `/api/public/brand/files/${req.file.filename}`
const legacyPath = `/brand/${req.file.filename}`
res.status(201).json({
ok: true,
filename: req.file.filename,
size: req.file.size,
path: publicPath,
legacyPath,
url: publicPath,
})
})
})
router.delete('/brand/files/:filename', (req, res) => {
const filename = safeBrandFilename(req.params.filename)
const filePath = path.join(filesDir, filename)
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
fs.unlinkSync(filePath)
res.json({ ok: true })
})
router.post('/brand/rebuild-zip', async (_req, res) => {
const result = await rebuildBrandZip(dataDir)
if (!result.ok) return res.status(500).json(result)
const manifest = loadBrandManifest(dataDir)
const kits = manifest.kits?.length
? manifest.kits.map((k) => (k.id === 'full' ? { ...k, path: result.path } : k))
: [{ id: 'full', title: 'Complete brand kit (ZIP)', path: result.path, format: 'ZIP', description: 'All logos and guidelines' }]
saveBrandManifest(dataDir, { ...manifest, kits })
res.json(result)
})
return router
}
+16
View File
@@ -0,0 +1,16 @@
import express from 'express'
import { getQrcodeImagePath } from '../lib/qr-provider.js'
/** Public serve for generated QR PNGs (opaque UUID ids). */
export function createQrPublicRoutes({ dataDir }) {
const router = express.Router()
router.get('/qr/:id', (req, res) => {
const filePath = getQrcodeImagePath(dataDir, req.params.id)
if (!filePath) return res.status(404).json({ error: 'QR image not found' })
res.setHeader('Cache-Control', 'public, max-age=3600')
res.sendFile(filePath)
})
return router
}
+164
View File
@@ -0,0 +1,164 @@
import express from 'express'
import {
QR_PRESETS,
createQrcode,
defaultMimCustomizations,
getLocalQrcode,
getQrcode,
getQrcodeImagePath,
getQrcodeScans,
getQrStatus,
listQrcodes,
mimQrBrandSpec,
updateQrcode,
} from '../lib/qr-provider.js'
import { createAuthMiddleware, requireRole } from '../middleware/auth.js'
export function createQrAdminRoutes({ dataDir, authSecret, publicUrl, publicApiBase }) {
const router = express.Router()
const auth = createAuthMiddleware({ dataDir, authSecret })
const siteBase = String(publicUrl || process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '')
const apiBase = publicApiBase || ''
const listOpts = { publicApiBase: apiBase, publicSiteBase: siteBase }
router.use(auth)
router.use(requireRole('admin'))
router.get('/qrcodes/status', async (_req, res) => {
try {
res.json(await getQrStatus(siteBase))
} catch (e) {
res.status(502).json({ error: e.message || 'Failed to load QR status' })
}
})
router.get('/qrcodes/presets', (_req, res) => {
const presets = Object.fromEntries(
Object.entries(QR_PRESETS).map(([key, p]) => [
key,
{ ...p, url: p.url.replace('https://mim4u.org', siteBase) },
]),
)
res.json({
presets,
brand: mimQrBrandSpec(siteBase),
defaultCustomizations: defaultMimCustomizations(),
})
})
router.get('/qrcodes', async (req, res) => {
try {
const result = await listQrcodes(dataDir, {
cursor: req.query.cursor,
limit: Math.min(Number(req.query.limit) || 20, 50),
...listOpts,
})
res.json(result)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to list QR codes' })
}
})
router.post('/qrcodes', express.json({ limit: '32kb' }), async (req, res) => {
const url = String(req.body?.url || '').trim()
const title = String(req.body?.title || 'MIM4U QR Code').trim().slice(0, 150)
if (!url || !/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'url must be a valid http(s) URL' })
}
try {
const created = await createQrcode(dataDir, {
url,
title,
purpose: req.body?.purpose || null,
customizations: req.body?.customizations,
publicUrl: siteBase,
...listOpts,
createdBy: req.user?.email,
})
res.status(201).json(created)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to create QR code', details: e.body })
}
})
router.post('/qrcodes/presets/:preset', express.json({ limit: '8kb' }), async (req, res) => {
const preset = QR_PRESETS[req.params.preset]
if (!preset) return res.status(404).json({ error: 'Unknown preset' })
const url = String(req.body?.url || preset.url).replace('https://mim4u.org', siteBase)
const title = String(req.body?.title || preset.title).trim().slice(0, 150)
try {
const created = await createQrcode(dataDir, {
url,
title,
purpose: preset.purpose,
preset: req.params.preset,
customizations: req.body?.customizations,
publicUrl: siteBase,
...listOpts,
createdBy: req.user?.email,
})
res.status(201).json(created)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to create preset QR code', details: e.body })
}
})
router.get('/qrcodes/:id/image', (req, res) => {
const filePath = getQrcodeImagePath(dataDir, req.params.id)
if (!filePath) return res.status(404).json({ error: 'QR image not found' })
res.sendFile(filePath)
})
router.get('/qrcodes/:id', async (req, res) => {
try {
const row = await getQrcode(dataDir, req.params.id, apiBase, siteBase)
if (!row) return res.status(404).json({ error: 'QR code not found' })
res.json(row)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to fetch QR code' })
}
})
router.patch('/qrcodes/:id', express.json({ limit: '32kb' }), async (req, res) => {
const patch = {}
if (req.body?.status === 'active' || req.body?.status === 'paused') patch.status = req.body.status
if (req.body?.url) patch.url = req.body.url
if (req.body?.title) patch.title = String(req.body.title).slice(0, 150)
if (req.body?.customizations) patch.customizations = req.body.customizations
if (!Object.keys(patch).length) {
return res.status(400).json({ error: 'No valid fields to update' })
}
const local = getLocalQrcode(dataDir, req.params.id)
if (local?.provider === 'qrcode-monkey' && (patch.url || patch.customizations)) {
return res.status(400).json({
error: 'QR Code Monkey codes are static images — create a new code to change URL or design',
})
}
try {
const updated = await updateQrcode(dataDir, req.params.id, patch, apiBase, siteBase)
res.json(updated)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to update QR code' })
}
})
router.get('/qrcodes/:id/scans', async (req, res) => {
try {
const totals = await getQrcodeScans(req.params.id)
res.json(totals)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to fetch scan totals' })
}
})
return router
}
/** @deprecated use createQrAdminRoutes */
export const createQrcgAdminRoutes = createQrAdminRoutes
+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}`)
})
+37
View File
@@ -0,0 +1,37 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { defaultMimCustomizations } from '../src/lib/qrcg.js'
import { defaultMonkeyConfig } from '../src/lib/qr-monkey.js'
import { mimQrBrandSpec } from '../src/lib/qr-brand.js'
import { getQrProvider } from '../src/lib/qr-provider.js'
describe('qr brand customizations', () => {
it('qrcg defaults use forest green / gold diamond styling', () => {
const c = defaultMimCustomizations()
assert.equal(c.background.color, '#1a3c34')
assert.equal(c.pattern.color, '#c5a059')
assert.equal(c.pattern.shape, 'diamond')
})
it('qrcode monkey defaults match approved reference', () => {
const c = defaultMonkeyConfig('https://mim4u.org')
assert.equal(c.bgColor, '#1a3c34')
assert.equal(c.bodyColor, '#c5a059')
assert.equal(c.body, 'diamond')
assert.equal(c.eye, 'frame14')
assert.match(c.logo, /logo-square/)
})
it('exposes brand spec with reference image path', () => {
const spec = mimQrBrandSpec('https://mim4u.org')
assert.equal(spec.referenceImage, '/brand/qr-code-branded-reference.png')
assert.equal(spec.colors.background, '#1a3c34')
})
it('defaults to qrcode-monkey provider', () => {
const prev = process.env.MIM_QR_PROVIDER
delete process.env.MIM_QR_PROVIDER
assert.equal(getQrProvider(), 'qrcode-monkey')
if (prev) process.env.MIM_QR_PROVIDER = prev
})
})
+114 -16
View File
@@ -1,21 +1,9 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
/** Inline validation mirror — keep in sync with server.js validateAssistance */
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
}
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createApp, validateAssistance } from '../src/app.js'
describe('assistance validation shape', () => {
it('requires core fields', () => {
@@ -28,3 +16,113 @@ describe('assistance validation shape', () => {
assert.ok(errors.includes('spam detected'))
})
})
describe('admin auth and settings', () => {
it('logs in admin and updates stripe settings', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mim-api-test-'))
const { app, AUTH_SECRET } = createApp({ dataDir: tmp, authSecret: 'test-secret', port: 0 })
const server = app.listen(0)
const { port } = server.address()
const loginRes = await fetch(`http://127.0.0.1:${port}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', password: 'demo123' }),
})
assert.equal(loginRes.status, 200)
const { token } = await loginRes.json()
assert.ok(token)
const settingsRes = await fetch(`http://127.0.0.1:${port}/api/admin/settings`, {
headers: { Authorization: `Bearer ${token}` },
})
assert.equal(settingsRes.status, 200)
const patchRes = await fetch(`http://127.0.0.1:${port}/api/admin/settings`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
donationsEnabled: true,
stripePublishableKey: 'pk_test_abc',
stripeSecretKey: 'sk_test_abc',
stripeWebhookSecret: 'whsec_test',
}),
})
assert.equal(patchRes.status, 200)
const patched = await patchRes.json()
assert.equal(patched.donationsEnabled, true)
assert.match(patched.stripeSecretKey, /…/)
const publicRes = await fetch(`http://127.0.0.1:${port}/api/public/config`)
const pub = await publicRes.json()
assert.equal(pub.stripeConfigured, true)
assert.equal(pub.stripePublishableKey, 'pk_test_abc')
server.close()
fs.rmSync(tmp, { recursive: true, force: true })
assert.ok(AUTH_SECRET)
})
})
describe('qr admin routes', () => {
it('reports qrcode-monkey as default provider', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mim-api-qr-'))
const { app } = createApp({ dataDir: tmp, authSecret: 'test-secret', port: 0 })
const server = app.listen(0)
const { port } = server.address()
const loginRes = await fetch(`http://127.0.0.1:${port}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', password: 'demo123' }),
})
const { token } = await loginRes.json()
const statusRes = await fetch(`http://127.0.0.1:${port}/api/admin/qrcodes/status`, {
headers: { Authorization: `Bearer ${token}` },
})
assert.equal(statusRes.status, 200)
const status = await statusRes.json()
assert.equal(status.provider, 'qrcode-monkey')
assert.equal(status.configured, true)
assert.equal(status.dynamicTracking, false)
server.close()
fs.rmSync(tmp, { recursive: true, force: true })
})
})
describe('brand manifest', () => {
it('seeds and serves public brand manifest', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mim-api-brand-'))
const { app } = createApp({ dataDir: tmp, authSecret: 'test-secret', port: 0 })
const server = app.listen(0)
const { port } = server.address()
const publicRes = await fetch(`http://127.0.0.1:${port}/api/public/brand`)
assert.equal(publicRes.status, 200)
const manifest = await publicRes.json()
assert.ok(manifest.groups?.length > 0)
assert.ok(manifest.colors?.length > 0)
const loginRes = await fetch(`http://127.0.0.1:${port}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', password: 'demo123' }),
})
const { token } = await loginRes.json()
const patchRes = await fetch(`http://127.0.0.1:${port}/api/admin/brand`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ published: false }),
})
assert.equal(patchRes.status, 200)
const hidden = await fetch(`http://127.0.0.1:${port}/api/public/brand`)
const hiddenJson = await hidden.json()
assert.equal(hiddenJson.published, false)
server.close()
fs.rmSync(tmp, { recursive: true, force: true })
})
})