Initial commit
Next.js + Express event management app for Hope Family Church.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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());
|
||||
Reference in New Issue
Block a user