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:
+28
-1
@@ -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=
|
||||
|
||||
Generated
+1040
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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}`)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user