2026-02-16 19:43:02 -07:00

466 lines
23 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { PrismaClient, UserRole, EmailTemplateCategory } from '@prisma/client';
import bcrypt from 'bcryptjs';
import * as fs from 'fs';
import * as path from 'path';
import { env } from '../src/config/env';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
// Create default super admin from environment variables
// Skip creation if password is a placeholder (contains "CHANGE_THIS")
const initialAdminEmail = env.INITIAL_ADMIN_EMAIL;
const initialAdminPassword = env.INITIAL_ADMIN_PASSWORD;
let admin: { id: string; email: string } | null = null;
// Validate password meets policy before proceeding
if (initialAdminPassword.length < 12 ||
!/[A-Z]/.test(initialAdminPassword) ||
!/[a-z]/.test(initialAdminPassword) ||
!/[0-9]/.test(initialAdminPassword)) {
console.warn('⚠️ INITIAL_ADMIN_PASSWORD does not meet password policy (12+ chars, uppercase, lowercase, digit)');
console.warn('⚠️ Skipping admin user creation. Please set a valid password in .env');
} else if (initialAdminPassword.includes('CHANGE_THIS') || initialAdminPassword.includes('REQUIRED')) {
console.warn('⚠️ INITIAL_ADMIN_PASSWORD contains placeholder value');
console.warn('⚠️ Skipping admin user creation. Please set a real password in .env');
} else {
const hashedPassword = await bcrypt.hash(initialAdminPassword, 10);
admin = await prisma.user.upsert({
where: { email: initialAdminEmail },
update: {
password: hashedPassword,
emailVerified: true,
status: 'ACTIVE',
},
create: {
email: initialAdminEmail,
password: hashedPassword,
name: 'Admin',
role: UserRole.SUPER_ADMIN,
roles: JSON.parse(JSON.stringify([UserRole.SUPER_ADMIN])),
emailVerified: true,
},
});
console.log(`✅ Created/updated admin user: ${admin.email}`);
}
// If admin wasn't created (placeholder password), try to find existing admin
if (!admin) {
admin = await prisma.user.findFirst({
where: { role: UserRole.SUPER_ADMIN },
select: { id: true, email: true },
});
if (admin) {
console.log(` Found existing admin user: ${admin.email}`);
}
}
// Create default map settings
await prisma.mapSettings.upsert({
where: { id: 'default' },
update: {},
create: {
id: 'default',
latitude: 53.5461,
longitude: -113.4938,
zoom: 11,
walkSheetTitle: 'Walk Sheet',
walkSheetSubtitle: '',
walkSheetFooter: '',
},
});
console.log('Created default map settings');
// Phase 3: v1 data migration will go here
// - Export NocoDB data via API
// - Import influence_users + login tables → unified users
// - Deduplicate by email
// - Hash plaintext passwords
// - Import campaigns, locations, shifts, cuts, etc.
// Create default page blocks for landing page builder
const defaultBlocks = [
{
id: 'default-hero',
type: 'hero',
label: 'Hero Section',
category: 'Headers',
sortOrder: 1,
schema: {
title: { type: 'string', label: 'Title' },
subtitle: { type: 'string', label: 'Subtitle' },
backgroundImage: { type: 'string', label: 'Background Image URL' },
ctaText: { type: 'string', label: 'Button Text' },
ctaUrl: { type: 'string', label: 'Button URL' },
},
defaults: {
title: 'Welcome to Our Campaign',
subtitle: 'Join us in making a difference in your community.',
backgroundImage: '',
ctaText: 'Get Involved',
ctaUrl: '#',
},
},
{
id: 'default-text',
type: 'text',
label: 'Text Block',
category: 'Content',
sortOrder: 2,
schema: {
heading: { type: 'string', label: 'Heading' },
body: { type: 'text', label: 'Body Text' },
},
defaults: {
heading: 'About Us',
body: 'Tell your story here. Explain your mission, values, and what drives your campaign forward.',
},
},
{
id: 'default-features',
type: 'features',
label: 'Features Grid',
category: 'Content',
sortOrder: 3,
schema: {
features: { type: 'array', label: 'Features', items: { title: 'string', description: 'string', icon: 'string' } },
},
defaults: {
features: [
{ title: 'Community Action', description: 'Organize local events and initiatives.', icon: '' },
{ title: 'Advocacy', description: 'Email your representatives directly.', icon: '' },
{ title: 'Volunteer', description: 'Sign up for shifts and make a difference.', icon: '' },
],
},
},
{
id: 'default-cta',
type: 'cta',
label: 'Call to Action',
category: 'Actions',
sortOrder: 4,
schema: {
heading: { type: 'string', label: 'Heading' },
description: { type: 'string', label: 'Description' },
buttonText: { type: 'string', label: 'Button Text' },
buttonUrl: { type: 'string', label: 'Button URL' },
},
defaults: {
heading: 'Ready to Take Action?',
description: 'Join thousands of community members making their voices heard.',
buttonText: 'Join Now',
buttonUrl: '#',
},
},
{
id: 'default-testimonials',
type: 'testimonials',
label: 'Testimonials',
category: 'Content',
sortOrder: 5,
schema: {
quotes: { type: 'array', label: 'Quotes', items: { text: 'string', author: 'string', role: 'string' } },
},
defaults: {
quotes: [
{ text: 'This platform made it so easy to contact my representatives.', author: 'Jane D.', role: 'Community Member' },
{ text: 'I signed up for a volunteer shift and it changed my perspective.', author: 'Mark S.', role: 'Volunteer' },
],
},
},
{
id: 'default-contact-form',
type: 'contact-form',
label: 'Contact Form',
category: 'Actions',
sortOrder: 6,
schema: {
heading: { type: 'string', label: 'Heading' },
fields: { type: 'array', label: 'Fields', items: { name: 'string', type: 'string', required: 'boolean' } },
},
defaults: {
heading: 'Get in Touch',
fields: [
{ name: 'Name', type: 'text', required: true },
{ name: 'Email', type: 'email', required: true },
{ name: 'Message', type: 'textarea', required: true },
],
},
},
{
id: 'default-video',
type: 'video',
label: 'Video Player',
category: 'Media',
sortOrder: 7,
schema: {
videoId: { type: 'number', label: 'Video ID', required: true },
playerType: { type: 'select', label: 'Player Type', options: ['standard', 'advanced'], default: 'standard' },
width: { type: 'string', label: 'Width', default: '100%' },
height: { type: 'string', label: 'Height', default: 'auto' },
autoplay: { type: 'boolean', label: 'Autoplay', default: false },
controls: { type: 'boolean', label: 'Show Controls', default: true },
showReactions: { type: 'boolean', label: 'Show Reactions (Advanced Only)', default: true },
},
defaults: {
videoId: null,
playerType: 'standard',
width: '100%',
height: 'auto',
autoplay: false,
controls: true,
showReactions: true,
},
},
];
for (const block of defaultBlocks) {
await prisma.pageBlock.upsert({
where: { id: block.id },
update: {},
create: block,
});
}
console.log(`Created ${defaultBlocks.length} default page blocks`);
// Seed email templates (skip if no admin user)
if (admin) {
await seedEmailTemplates(admin);
} else {
console.warn('⚠️ No admin user found - skipping email template seeding');
}
console.log('Seed complete.');
}
/**
* Seed email templates from filesystem
*/
async function seedEmailTemplates(admin: { id: string; email: string }) {
console.log('Seeding email templates...');
const templatesDir = path.join(__dirname, '../src/templates/email');
const templateDefinitions = [
{
key: 'campaign-email',
name: 'Campaign Email to Representative',
description: 'Email sent when a constituent contacts their elected representative through a campaign',
category: EmailTemplateCategory.INFLUENCE,
subjectLine: '{{CAMPAIGN_TITLE}} - Message from {{USER_NAME}}',
isSystem: true,
variables: [
{ key: 'CAMPAIGN_TITLE', label: 'Campaign Title', description: 'Title of the advocacy campaign', isRequired: true, isConditional: false, sampleValue: 'Support Climate Action Bill C-12', sortOrder: 0 },
{ key: 'MESSAGE', label: 'Message Body', description: 'The message content written by the constituent', isRequired: true, isConditional: false, sampleValue: 'I urge you to support this important legislation...', sortOrder: 1 },
{ key: 'USER_NAME', label: 'Sender Name', description: 'Name of the constituent sending the email', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 2 },
{ key: 'USER_EMAIL', label: 'Sender Email', description: 'Email address of the constituent', isRequired: true, isConditional: false, sampleValue: 'jane@example.com', sortOrder: 3 },
{ key: 'POSTAL_CODE', label: 'Postal Code', description: 'Postal code of the constituent', isRequired: true, isConditional: false, sampleValue: 'K1A 0B1', sortOrder: 4 },
{ key: 'RECIPIENT_NAME', label: 'Representative Name', description: 'Name of the representative receiving the email', isRequired: false, isConditional: true, sampleValue: 'Hon. John Smith', sortOrder: 5 },
{ key: 'RECIPIENT_LEVEL', label: 'Government Level', description: 'Level of government', isRequired: false, isConditional: true, sampleValue: 'Federal', sortOrder: 6 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 7 },
{ key: 'TIMESTAMP', label: 'Timestamp', description: 'Date and time the email was sent', isRequired: true, isConditional: false, sampleValue: '2025-02-11T10:30:00Z', sortOrder: 8 },
],
},
{
key: 'response-verification',
name: 'Response Verification Email',
description: 'Email sent to representatives to verify a submitted response',
category: EmailTemplateCategory.INFLUENCE,
subjectLine: 'Verify Your Response - {{CAMPAIGN_TITLE}}',
isSystem: true,
variables: [
{ key: 'CAMPAIGN_TITLE', label: 'Campaign Title', description: 'Title of the advocacy campaign', isRequired: true, isConditional: false, sampleValue: 'Support Climate Action Bill C-12', sortOrder: 0 },
{ key: 'RESPONSE_TYPE', label: 'Response Type', description: 'Type of response', isRequired: true, isConditional: false, sampleValue: 'SUPPORT', sortOrder: 1 },
{ key: 'RESPONSE_TEXT', label: 'Response Text', description: 'The text of the response submitted', isRequired: true, isConditional: false, sampleValue: 'I fully support this initiative...', sortOrder: 2 },
{ key: 'SUBMITTER_NAME', label: 'Submitter Name', description: 'Name of the person who submitted the response', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 3 },
{ key: 'SUBMITTED_DATE', label: 'Submitted Date', description: 'Date the response was submitted', isRequired: true, isConditional: false, sampleValue: 'February 11, 2025', sortOrder: 4 },
{ key: 'VERIFICATION_URL', label: 'Verification URL', description: 'URL to verify the response', isRequired: true, isConditional: false, sampleValue: 'https://api.cmlite.org/api/responses/123/verify/abc123', sortOrder: 5 },
{ key: 'REPORT_URL', label: 'Report URL', description: 'URL to report the response as invalid', isRequired: true, isConditional: false, sampleValue: 'https://api.cmlite.org/api/responses/123/report/abc123', sortOrder: 6 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 7 },
{ key: 'TIMESTAMP', label: 'Timestamp', description: 'Date and time the verification email was sent', isRequired: true, isConditional: false, sampleValue: '2025-02-11T10:30:00Z', sortOrder: 8 },
],
},
{
key: 'shift-signup-confirmation',
name: 'Shift Signup Confirmation',
description: 'Email sent when a volunteer signs up for a shift',
category: EmailTemplateCategory.MAP,
subjectLine: 'Shift Confirmation - {{SHIFT_TITLE}}',
isSystem: true,
variables: [
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 0 },
{ key: 'USER_NAME', label: 'Volunteer Name', description: 'Name of the volunteer', isRequired: true, isConditional: false, sampleValue: 'John Smith', sortOrder: 1 },
{ key: 'USER_EMAIL', label: 'Volunteer Email', description: 'Email address of the volunteer', isRequired: true, isConditional: false, sampleValue: 'john@example.com', sortOrder: 2 },
{ key: 'SHIFT_TITLE', label: 'Shift Title', description: 'Title of the volunteer shift', isRequired: true, isConditional: false, sampleValue: 'Weekend Canvassing - Downtown', sortOrder: 3 },
{ key: 'SHIFT_DATE', label: 'Shift Date', description: 'Date of the volunteer shift', isRequired: true, isConditional: false, sampleValue: 'Saturday, February 15, 2025', sortOrder: 4 },
{ key: 'SHIFT_TIME', label: 'Shift Time', description: 'Time of the volunteer shift', isRequired: true, isConditional: false, sampleValue: '10:00 AM — 2:00 PM', sortOrder: 5 },
{ key: 'SHIFT_LOCATION', label: 'Shift Location', description: 'Location of the volunteer shift', isRequired: true, isConditional: false, sampleValue: '123 Main Street', sortOrder: 6 },
{ key: 'IS_NEW_USER', label: 'Is New User', description: 'Whether this is a new user account', isRequired: false, isConditional: true, sampleValue: 'true', sortOrder: 7 },
{ key: 'TEMP_PASSWORD', label: 'Temporary Password', description: 'Temporary password for new user account', isRequired: false, isConditional: true, sampleValue: 'SwiftEagle42', sortOrder: 8 },
{ key: 'LOGIN_URL', label: 'Login URL', description: 'URL to log in to the system', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/login', sortOrder: 9 },
],
},
{
key: 'shift-details',
name: 'Shift Details Reminder',
description: 'Reminder email sent to all volunteers signed up for a shift',
category: EmailTemplateCategory.MAP,
subjectLine: 'Shift Reminder - {{SHIFT_TITLE}} on {{SHIFT_DATE}}',
isSystem: true,
variables: [
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 0 },
{ key: 'USER_NAME', label: 'Volunteer Name', description: 'Name of the volunteer', isRequired: true, isConditional: false, sampleValue: 'John Smith', sortOrder: 1 },
{ key: 'SHIFT_TITLE', label: 'Shift Title', description: 'Title of the volunteer shift', isRequired: true, isConditional: false, sampleValue: 'Weekend Canvassing - Downtown', sortOrder: 2 },
{ key: 'SHIFT_DATE', label: 'Shift Date', description: 'Date of the volunteer shift', isRequired: true, isConditional: false, sampleValue: 'Saturday, February 15, 2025', sortOrder: 3 },
{ key: 'SHIFT_START_TIME', label: 'Start Time', description: 'Start time of the shift', isRequired: true, isConditional: false, sampleValue: '10:00 AM', sortOrder: 4 },
{ key: 'SHIFT_END_TIME', label: 'End Time', description: 'End time of the shift', isRequired: true, isConditional: false, sampleValue: '2:00 PM', sortOrder: 5 },
{ key: 'SHIFT_LOCATION', label: 'Shift Location', description: 'Location of the volunteer shift', isRequired: true, isConditional: false, sampleValue: '123 Main Street', sortOrder: 6 },
{ key: 'SHIFT_DESCRIPTION', label: 'Shift Description', description: 'Description of the shift', isRequired: false, isConditional: true, sampleValue: 'We will be canvassing the downtown area...', sortOrder: 7 },
{ key: 'CURRENT_VOLUNTEERS', label: 'Current Volunteers', description: 'Number of volunteers currently signed up', isRequired: true, isConditional: false, sampleValue: '8', sortOrder: 8 },
{ key: 'MAX_VOLUNTEERS', label: 'Max Volunteers', description: 'Maximum number of volunteers allowed', isRequired: true, isConditional: false, sampleValue: '10', sortOrder: 9 },
{ key: 'SHIFT_STATUS', label: 'Shift Status', description: 'Status of the shift', isRequired: true, isConditional: false, sampleValue: 'OPEN', sortOrder: 10 },
],
},
{
key: 'email-verification',
name: 'Email Verification',
description: 'Sent to new users to verify their email address after self-registration',
category: EmailTemplateCategory.SYSTEM,
subjectLine: 'Verify your email — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'User Name', description: 'Name of the user being verified', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'VERIFICATION_URL', label: 'Verification URL', description: 'URL the user clicks to verify their email', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/verify-email?token=abc123', sortOrder: 1 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 2 },
{ key: 'EXPIRY_HOURS', label: 'Expiry Hours', description: 'Number of hours until the verification link expires', isRequired: true, isConditional: false, sampleValue: '24', sortOrder: 3 },
],
},
{
key: 'password-reset',
name: 'Password Reset',
description: 'Sent when a user requests a password reset link',
category: EmailTemplateCategory.SYSTEM,
subjectLine: 'Reset your password — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'User Name', description: 'Name of the user requesting a reset', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'RESET_URL', label: 'Reset URL', description: 'URL the user clicks to reset their password', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/reset-password?token=abc123', sortOrder: 1 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 2 },
],
},
{
key: 'account-approved',
name: 'Account Approved',
description: 'Sent to a user when an admin approves their account after email verification',
category: EmailTemplateCategory.SYSTEM,
subjectLine: 'Account approved — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'User Name', description: 'Name of the approved user', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'LOGIN_URL', label: 'Login URL', description: 'URL to the login page', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/login', sortOrder: 1 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 2 },
],
},
{
key: 'account-pending-approval',
name: 'Account Pending Approval (Admin Notification)',
description: 'Sent to admins when a new user verifies their email and is awaiting approval',
category: EmailTemplateCategory.SYSTEM,
subjectLine: 'New user awaiting approval — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'NEW_USER_NAME', label: 'New User Name', description: 'Name of the new user requesting approval', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'NEW_USER_EMAIL', label: 'New User Email', description: 'Email of the new user', isRequired: true, isConditional: false, sampleValue: 'jane@example.com', sortOrder: 1 },
{ key: 'REGISTERED_AT', label: 'Registration Date', description: 'Date the user registered', isRequired: true, isConditional: false, sampleValue: 'February 16, 2026', sortOrder: 2 },
{ key: 'ADMIN_URL', label: 'Admin URL', description: 'URL to the admin users page filtered to pending approval', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/app/users?status=PENDING_APPROVAL', sortOrder: 3 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 4 },
],
},
];
let seededCount = 0;
let skippedCount = 0;
for (const templateDef of templateDefinitions) {
try {
// Check if template already exists
const existing = await prisma.emailTemplate.findUnique({
where: { key: templateDef.key },
});
if (existing) {
skippedCount++;
continue;
}
// Read HTML and TXT files from filesystem
const htmlPath = path.join(templatesDir, `${templateDef.key}.html`);
const txtPath = path.join(templatesDir, `${templateDef.key}.txt`);
if (!fs.existsSync(htmlPath) || !fs.existsSync(txtPath)) {
console.warn(` ⚠️ Template files not found for ${templateDef.key}`);
continue;
}
const htmlContent = fs.readFileSync(htmlPath, 'utf-8');
const textContent = fs.readFileSync(txtPath, 'utf-8');
// Create template with variables and initial version
await prisma.$transaction(async (tx) => {
const template = await tx.emailTemplate.create({
data: {
key: templateDef.key,
name: templateDef.name,
description: templateDef.description,
category: templateDef.category,
subjectLine: templateDef.subjectLine,
htmlContent,
textContent,
isSystem: templateDef.isSystem,
isActive: true,
createdByUserId: admin.id,
variables: {
create: templateDef.variables,
},
},
});
// Create initial version
await tx.emailTemplateVersion.create({
data: {
templateId: template.id,
versionNumber: 1,
subjectLine: templateDef.subjectLine,
htmlContent,
textContent,
changeNotes: 'Initial migration from filesystem',
createdByUserId: admin.id,
},
});
});
seededCount++;
} catch (error) {
console.error(` ❌ Error seeding template ${templateDef.key}:`, error);
}
}
console.log(`Email templates seeded: ${seededCount} created, ${skippedCount} skipped`);
}
main()
.catch((e) => {
console.error('Seed error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});