Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
/**
* migrate-env-to-db.js
*
* Reads settings from environment variables (.env) and writes them into the
* AppSetting table — only for keys that are NOT already set in the database.
*
* Safe to run on a live site: it never overwrites an existing DB value.
* Sensitive keys (smtp_user, smtp_pass) are AES-256-GCM encrypted before storage.
*
* Usage:
* cd backend
* node scripts/migrate-env-to-db.js
*
* Add --dry-run to preview what would be written without touching the DB.
* Add --force to overwrite existing DB values (use with caution on live data).
*/
'use strict';
const path = require('path');
const fs = require('fs');
const dotenv = require('dotenv');
// Load .env from the backend root
dotenv.config({ path: path.resolve(__dirname, '../.env') });
const { PrismaClient } = require('@prisma/client');
const crypto = require('crypto');
const DRY_RUN = process.argv.includes('--dry-run');
const FORCE = process.argv.includes('--force');
// ─── Encryption (mirrors backend/src/utils/encryption.js) ────────────────────
const SENTINEL = 'enc:';
const ENCRYPTION_KEY = (() => {
const secret = process.env.JWT_SECRET || 'default-unsafe-key-CHANGE-IN-PRODUCTION';
if (!process.env.JWT_SECRET) {
console.warn('[warn] JWT_SECRET is not set — encrypted values will use an insecure fallback key.');
}
return crypto.scryptSync(secret, 'hope-events-settings-aes256gcm-v1', 32);
})();
function encrypt(plaintext) {
if (!plaintext) return plaintext;
if (plaintext.startsWith(SENTINEL)) return plaintext;
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${enc.toString('hex')}`;
}
const ENCRYPTED_KEYS = new Set(['smtp_user', 'smtp_pass']);
// ─── Mapping: env var(s) → DB key ────────────────────────────────────────────
// Each entry: { key: DB key, envVars: [...candidates in priority order], encrypt?: bool }
// The first non-empty env var found wins.
const MAPPINGS = [
// Organisation
{ key: 'org_name', envVars: ['ORG_NAME'] },
{ key: 'org_tagline', envVars: ['ORG_TAGLINE'] },
{ key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] },
// Branding
{ key: 'accent_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
// Notifications
{ key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] },
// SMTP
{ key: 'smtp_host', envVars: ['SMTP_HOST', 'EMAIL_HOST'] },
{ key: 'smtp_port', envVars: ['SMTP_PORT', 'EMAIL_PORT'] },
{ key: 'smtp_secure', envVars: ['SMTP_SECURE', 'EMAIL_SECURE'] },
{ key: 'smtp_from', envVars: ['MAIL_FROM', 'EMAIL_FROM'] },
{ key: 'smtp_user', envVars: ['SMTP_USER', 'EMAIL_USER'], encrypt: true },
{ key: 'smtp_pass', envVars: ['SMTP_PASS', 'EMAIL_PASS'], encrypt: true },
// App URLs (used in email templates)
{ key: 'app_base_url', envVars: ['FRONTEND_URL', 'APP_BASE_URL'] },
];
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main() {
const prisma = new PrismaClient();
console.log(`\n${'─'.repeat(60)}`);
console.log(' Hope Events — .env → DB settings migration');
if (DRY_RUN) console.log(' MODE: DRY RUN (no changes will be made)');
if (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)');
console.log(`${'─'.repeat(60)}\n`);
try {
// Fetch all existing DB settings once
const existing = await prisma.appSetting.findMany();
const dbMap = new Map(existing.map(r => [r.key, r.value]));
console.log(`Found ${dbMap.size} existing setting(s) in the database.\n`);
const toWrite = [];
const skipped = [];
const noEnvVal = [];
for (const { key, envVars, encrypt: shouldEncrypt } of MAPPINGS) {
// Find first non-empty env var value
let rawValue = '';
let sourceVar = '';
for (const v of envVars) {
if (process.env[v] && process.env[v].trim()) {
rawValue = process.env[v].trim();
sourceVar = v;
break;
}
}
if (!rawValue) {
noEnvVal.push({ key, envVars });
continue;
}
const dbHasValue = dbMap.has(key) && dbMap.get(key) !== '';
if (dbHasValue && !FORCE) {
skipped.push({ key, reason: 'already set in DB (use --force to overwrite)' });
continue;
}
const storedValue = (shouldEncrypt || ENCRYPTED_KEYS.has(key))
? encrypt(rawValue)
: rawValue;
toWrite.push({ key, rawValue, storedValue, sourceVar, encrypted: !!(shouldEncrypt || ENCRYPTED_KEYS.has(key)), overwriting: dbHasValue });
}
// ── Report what will be written ───────────────────────────────────────
if (toWrite.length === 0) {
console.log('Nothing to write.\n');
} else {
console.log(`Will write ${toWrite.length} setting(s):\n`);
for (const { key, rawValue, sourceVar, encrypted, overwriting } of toWrite) {
const display = encrypted ? '(encrypted)' : `"${rawValue}"`;
const flag = overwriting ? ' [OVERWRITING]' : '';
console.log(`${key.padEnd(28)}${sourceVar} = ${display}${flag}`);
}
console.log('');
}
if (skipped.length > 0) {
console.log(`Skipped ${skipped.length} setting(s) (already in DB):\n`);
for (const { key, reason } of skipped) {
console.log(` · ${key.padEnd(28)} ${reason}`);
}
console.log('');
}
if (noEnvVal.length > 0) {
console.log(`No env value found for ${noEnvVal.length} setting(s) (nothing to migrate):\n`);
for (const { key, envVars: vars } of noEnvVal) {
console.log(` - ${key.padEnd(28)} checked: ${vars.join(', ')}`);
}
console.log('');
}
// ── Write ─────────────────────────────────────────────────────────────
if (DRY_RUN) {
console.log('Dry run complete — no changes made.\n');
return;
}
if (toWrite.length === 0) {
console.log('Nothing to do — all settings are already configured in the DB.\n');
return;
}
const ops = toWrite.map(({ key, storedValue }) =>
prisma.appSetting.upsert({
where: { key },
update: { value: storedValue },
create: { key, value: storedValue },
})
);
await prisma.$transaction(ops);
console.log(`\n✓ Successfully wrote ${toWrite.length} setting(s) to the database.\n`);
} finally {
await prisma.$disconnect();
}
}
main().catch(err => {
console.error('\n[error]', err.message || err);
process.exit(1);
});
+134
View File
@@ -0,0 +1,134 @@
/**
* Seed script: inserts 75 demo users (mix of roles, ~25 inactive)
* Run: node scripts/seed-users.js
*/
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const prisma = new PrismaClient();
const firstNames = [
'Liam', 'Olivia', 'Noah', 'Emma', 'James', 'Ava', 'Elijah', 'Sophia',
'Lucas', 'Isabella', 'Mason', 'Mia', 'Logan', 'Charlotte', 'Ethan',
'Amelia', 'Aiden', 'Harper', 'Jackson', 'Evelyn', 'Thabo', 'Zanele',
'Sipho', 'Nomsa', 'Lebo', 'Thandeka', 'Kagiso', 'Palesa', 'Bongani',
'Ayanda', 'Pieter', 'Anri', 'Francois', 'Elizma', 'Hendrik', 'Marius',
'Chloé', 'Dylan', 'Amber', 'Caleb', 'Zoe', 'Hunter', 'Lily', 'Owen',
'Grace', 'Ryan', 'Chloe', 'Nathan', 'Stella', 'Connor', 'Hazel',
'Miles', 'Violet', 'Eli', 'Aurora', 'Isaiah', 'Luna', 'Nolan',
'Scarlett', 'Wyatt', 'Penelope', 'Sebastian', 'Layla', 'Asher',
'Riley', 'Oliver', 'Zoey', 'Ezra', 'Nora', 'Micah', 'Hannah',
'Jonah', 'Leah', 'Aaron', 'Aubrey', 'Charles',
];
const lastNames = [
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller',
'Davis', 'Wilson', 'Moore', 'Taylor', 'Anderson', 'Thomas', 'Jackson',
'White', 'Harris', 'Martin', 'Thompson', 'Robinson', 'Lewis',
'Dlamini', 'Nkosi', 'Mokoena', 'Ndlovu', 'Mthembu', 'Zulu', 'Nxumalo',
'du Plessis', 'van der Berg', 'Botha', 'Steyn', 'Fourie', 'Pretorius',
'Venter', 'Coetzee', 'van Niekerk', 'Lombard', 'Janse van Rensburg',
'Walker', 'Hall', 'Allen', 'Young', 'Hernandez', 'King', 'Wright',
'Scott', 'Green', 'Baker', 'Adams', 'Nelson', 'Carter', 'Mitchell',
'Perez', 'Roberts', 'Turner', 'Phillips', 'Campbell', 'Parker', 'Evans',
'Edwards', 'Collins', 'Stewart', 'Morris', 'Reed', 'Cook', 'Morgan',
];
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function randomPhone() {
// SA mobile format: 07x/08x xxxxxxx
const prefixes = ['071', '072', '073', '074', '076', '078', '079', '081', '082', '083', '084'];
return `${pick(prefixes)}${String(Math.floor(Math.random() * 9000000) + 1000000)}`;
}
async function main() {
const passwordHash = await bcrypt.hash('Demo@1234', 10);
// Role distribution: ~55 user, ~15 staff, ~5 supervisor
const rolePool = [
...Array(55).fill('user'),
...Array(15).fill('staff'),
...Array(5).fill('supervisor'),
];
const notifPool = ['email', 'email', 'email', 'whatsapp', 'both'];
const usedEmails = new Set();
const usedPhones = new Set();
const users = [];
// Build 75 user records
while (users.length < 75) {
const first = pick(firstNames);
const last = pick(lastNames);
const tag = Math.floor(Math.random() * 999) + 1;
const email = `${first.toLowerCase().replace(/ /g, '')}.${last.toLowerCase().replace(/ /g, '').replace(/'/g, '')}${tag}@demo.church`;
if (usedEmails.has(email)) continue;
usedEmails.add(email);
// ~60% of users have a phone number
let phone = null;
if (Math.random() < 0.6) {
let attempt = randomPhone();
let tries = 0;
while (usedPhones.has(attempt) && tries < 20) {
attempt = randomPhone();
tries++;
}
if (!usedPhones.has(attempt)) {
usedPhones.add(attempt);
phone = attempt;
}
}
users.push({
name: `${first} ${last}`,
email,
password: passwordHash,
role: pick(rolePool),
isActive: true, // will override 25 below
phoneNumber: phone,
notificationPreference: pick(notifPool),
});
}
// Mark exactly 25 as inactive (spread across the list)
const inactiveIndexes = new Set();
while (inactiveIndexes.size < 25) {
inactiveIndexes.add(Math.floor(Math.random() * 75));
}
inactiveIndexes.forEach(i => { users[i].isActive = false; });
console.log(`Inserting ${users.length} users…`);
console.log(` Active: ${users.filter(u => u.isActive).length}`);
console.log(` Inactive: ${users.filter(u => !u.isActive).length}`);
console.log(` Roles: user=${users.filter(u => u.role === 'user').length}, staff=${users.filter(u => u.role === 'staff').length}, supervisor=${users.filter(u => u.role === 'supervisor').length}`);
let created = 0;
let skipped = 0;
for (const user of users) {
try {
await prisma.user.create({ data: user });
created++;
} catch (err) {
if (err.code === 'P2002') {
skipped++;
} else {
throw err;
}
}
}
console.log(`Done. Created: ${created}, Skipped (duplicate): ${skipped}`);
console.log('Default password for all demo users: Demo@1234');
}
main()
.catch(err => { console.error(err); process.exit(1); })
.finally(() => prisma.$disconnect());