Tonne of udpatess

This commit is contained in:
2026-02-18 10:01:54 -07:00
parent 99a6abab06
commit 56e262ad8b
197 changed files with 42200 additions and 968 deletions

View File

@@ -130,6 +130,7 @@ model User {
invoices Invoice[] @relation("UserInvoices")
payments Payment[] @relation("UserPayments")
paymentAudits PaymentAuditLog[] @relation("PaymentAuditUser")
orders Order[] @relation("UserOrders")
notifications Notification[] @relation("UserNotifications")
notificationPreferences NotificationPreferences? @relation("NotificationPreferences")
@@ -293,6 +294,8 @@ model CampaignEmail {
@@index([campaignId])
@@index([campaignSlug])
@@index([userPostalCode])
@@index([sentAt])
@@map("campaign_emails")
}
@@ -357,6 +360,7 @@ model RepresentativeResponse {
@@index([campaignId])
@@index([campaignSlug])
@@index([representativeName])
@@map("representative_responses")
}
@@ -779,6 +783,7 @@ model MapSettings {
qrCode2Label String?
qrCode3Url String?
qrCode3Label String?
publicMapEnabled Boolean @default(true)
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -836,6 +841,9 @@ model SiteSettings {
enableMap Boolean @default(true)
enableNewsletter Boolean @default(true)
enableLandingPages Boolean @default(true)
enableMediaFeatures Boolean @default(true) @map("enable_media_features")
enablePayments Boolean @default(false)
enableGalleryAds Boolean @default(false) @map("enable_gallery_ads")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -851,6 +859,7 @@ enum EmailTemplateCategory {
INFLUENCE
MAP
SYSTEM
PAYMENT
}
enum EmailTemplateVariableType {
@@ -1305,6 +1314,7 @@ enum SubscriptionStatus {
grace_period
delinquent
lifetime
cancelled
}
enum InvoiceStatus {
@@ -1318,12 +1328,27 @@ enum PaymentStatus {
pending
succeeded
failed
refunded
}
enum PaymentMethod {
card
bank_transfer
crypto
stripe
}
enum ProductType {
DIGITAL
EVENT
DONATION
}
enum OrderStatus {
PENDING
COMPLETED
FAILED
REFUNDED
}
enum NotificationType {
@@ -1402,6 +1427,9 @@ model Video {
averageWatchTimeSeconds Decimal @default(0) @map("average_watch_time_seconds") @db.Decimal(10, 2)
completionRate Decimal @default(0) @map("completion_rate") @db.Decimal(5, 2)
// Content gating
accessLevel String @default("free") @map("access_level") // free|member|premium
// Ordering
position Int? @default(0)
@@ -2018,17 +2046,28 @@ model Ad {
imagePath String? @map("image_path")
linkUrl String? @map("link_url")
title String?
subtitle String? @db.Text
ctaText String? @map("cta_text")
ctaStyle String? @default("primary") @map("cta_style")
bgColor String? @map("bg_color")
iconEmoji String? @map("icon_emoji")
isSystemAd Boolean @default(false) @map("is_system_ad")
frequency Int @default(6)
visibility String @default("everyone")
isActive Boolean? @default(true) @map("is_active")
position Int? @default(0)
impressionCount Int? @default(0) @map("impression_count")
clickCount Int? @default(0) @map("click_count")
startDate DateTime? @map("start_date")
endDate DateTime? @map("end_date")
productId String? @unique @map("product_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @map("updated_at")
// Relations
impressions AdImpression[]
clicks AdClick[]
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@index([type], map: "idx_ads_type")
@@index([isActive], map: "idx_ads_is_active")
@@ -3039,13 +3078,22 @@ model PipelineTemplate {
// ============================================================================
model SubscriptionPlan {
id Int @id @default(autoincrement())
name String
priceCAD Int @map("price_cad")
durationDays Int @map("duration_days")
features Json?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
name String
priceCAD Int @map("price_cad")
durationDays Int @map("duration_days")
features Json?
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripeProductId String? @map("stripe_product_id")
stripePriceId String? @map("stripe_price_id")
stripeYearlyPriceId String? @map("stripe_yearly_price_id")
yearlyPriceCAD Int? @map("yearly_price_cad")
description String? @db.Text
tier Int @default(0)
displayOrder Int @default(0) @map("display_order")
// Relations
subscriptions UserSubscription[]
@@ -3054,14 +3102,20 @@ model SubscriptionPlan {
}
model UserSubscription {
id Int @id @default(autoincrement())
userId String @map("user_id")
planId Int @map("plan_id")
status SubscriptionStatus @default(active)
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
userId String @map("user_id")
planId Int @map("plan_id")
status SubscriptionStatus @default(active)
startDate DateTime @map("start_date")
endDate DateTime @map("end_date")
cancelledAt DateTime? @map("cancelled_at")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
stripeCustomerId String? @map("stripe_customer_id")
currentPeriodEnd DateTime? @map("current_period_end")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
// Relations
user User @relation("UserSubscriptions", fields: [userId], references: [id])
@@ -3085,6 +3139,10 @@ model Invoice {
description String?
metadata Json?
// Stripe integration
stripeInvoiceId String? @unique @map("stripe_invoice_id")
type String @default("subscription") @map("invoice_type") // subscription|product|donation
// Relations
user User @relation("UserInvoices", fields: [userId], references: [id])
payments Payment[]
@@ -3096,16 +3154,20 @@ model Invoice {
}
model Payment {
id Int @id @default(autoincrement())
invoiceId Int @map("invoice_id")
userId String @map("user_id")
amountCAD Int @map("amount_cad")
method PaymentMethod
status PaymentStatus @default(pending)
externalId String? @map("external_id")
metadata Json?
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
id Int @id @default(autoincrement())
invoiceId Int @map("invoice_id")
userId String @map("user_id")
amountCAD Int @map("amount_cad")
method PaymentMethod
status PaymentStatus @default(pending)
externalId String? @map("external_id")
metadata Json?
processedAt DateTime? @map("processed_at")
createdAt DateTime @default(now()) @map("created_at")
// Stripe integration
stripePaymentIntentId String? @unique @map("stripe_payment_intent_id")
stripeCheckoutSessionId String? @map("stripe_checkout_session_id")
// Relations
invoice Invoice @relation(fields: [invoiceId], references: [id])
@@ -3138,6 +3200,87 @@ model PaymentAuditLog {
@@map("payment_audit_log")
}
model Product {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
priceCAD Int @map("price_cad")
type ProductType
stripeProductId String? @map("stripe_product_id")
stripePriceId String? @map("stripe_price_id")
isActive Boolean @default(true) @map("is_active")
imageUrl String? @map("image_url")
downloadUrl String? @map("download_url")
metadata Json?
maxPurchases Int? @map("max_purchases")
purchaseCount Int @default(0) @map("purchase_count")
createdByUserId String? @map("created_by_user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[]
galleryAd Ad?
@@index([type], map: "idx_products_type")
@@index([isActive], map: "idx_products_active")
@@map("products")
}
model Order {
id String @id @default(cuid())
userId String? @map("user_id")
productId String? @map("product_id")
amountCAD Int @map("amount_cad")
status OrderStatus @default(PENDING)
stripeCheckoutSessionId String? @unique @map("stripe_checkout_session_id")
stripePaymentIntentId String? @map("stripe_payment_intent_id")
type String @default("product") @map("order_type") // product|donation
// Buyer info (for guests)
buyerEmail String @map("buyer_email")
buyerName String? @map("buyer_name")
// Donation-specific
donorMessage String? @db.Text @map("donor_message")
isAnonymous Boolean @default(false) @map("is_anonymous")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User? @relation("UserOrders", fields: [userId], references: [id])
product Product? @relation(fields: [productId], references: [id])
@@index([userId], map: "idx_orders_user")
@@index([productId], map: "idx_orders_product")
@@index([status], map: "idx_orders_status")
@@index([type], map: "idx_orders_type")
@@map("orders")
}
model PaymentSettings {
id String @id @default(cuid())
stripeSecretKey String @default("") @map("stripe_secret_key")
stripePublishableKey String @default("") @map("stripe_publishable_key")
stripeWebhookSecret String @default("") @map("stripe_webhook_secret")
defaultCurrency String @default("cad") @map("default_currency")
// Donation settings
enableDonations Boolean @default(true) @map("enable_donations")
donationSuggestedAmounts Json @default("[1000, 2500, 5000, 10000]") @map("donation_suggested_amounts")
donationMinimum Int @default(500) @map("donation_minimum")
donationPageTitle String @default("Support Our Work") @map("donation_page_title")
donationPageDescription String? @db.Text @map("donation_page_description")
thankYouMessage String @default("Thank you for your support!") @db.Text @map("thank_you_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("payment_settings")
}
// ============================================================================
// NOTIFICATIONS
// ============================================================================
@@ -3299,3 +3442,20 @@ model VideoScheduleHistory {
@@index([scheduledByUserId], map: "idx_video_schedule_history_user")
@@map("video_schedule_history")
}
// ============================================================================
// DOCS ANALYTICS
// ============================================================================
model DocsPageView {
id String @id @default(cuid())
path String // e.g. "/docs/getting-started/"
referrer String? @db.Text // document.referrer
sessionHash String? // anonymous session UUID (sessionStorage)
userAgent String? // for device type breakdown
createdAt DateTime @default(now())
@@index([createdAt])
@@index([path, createdAt])
@@map("docs_page_views")
}

View File

@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
import * as fs from 'fs';
import * as path from 'path';
import { env } from '../src/config/env';
import { initEncryption, encrypt } from '../src/utils/crypto';
const prisma = new PrismaClient();
@@ -77,12 +78,32 @@ async function main() {
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.
// Seed SiteSettings with .env SMTP values (only if no row exists yet)
const existingSettings = await prisma.siteSettings.findFirst();
if (!existingSettings) {
// Initialize encryption so we can encrypt the SMTP password
const encryptionKey = env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET;
initEncryption(encryptionKey);
const isMailhog = env.EMAIL_TEST_MODE === 'true' || env.SMTP_HOST === 'mailhog-changemaker';
await prisma.siteSettings.create({
data: {
smtpHost: env.SMTP_HOST,
smtpPort: env.SMTP_PORT,
smtpUser: env.SMTP_USER,
smtpPass: env.SMTP_PASS ? encrypt(env.SMTP_PASS) : '',
smtpFromAddress: env.SMTP_FROM,
emailFromName: env.SMTP_FROM_NAME,
smtpActiveProvider: isMailhog ? 'mailhog' : 'production',
emailTestMode: env.EMAIL_TEST_MODE === 'true',
testEmailRecipient: env.TEST_EMAIL_RECIPIENT,
},
});
console.log('Created SiteSettings with SMTP config from .env');
} else {
console.log('SiteSettings already exists, skipping SMTP seeding');
}
// Create default page blocks for landing page builder
const defaultBlocks = [
@@ -239,6 +260,57 @@ async function main() {
viewCount: 0,
},
},
{
id: 'default-donate-button',
type: 'donate-button',
label: 'Donate Button',
category: 'Payments',
sortOrder: 9,
schema: {
buttonText: { type: 'string', label: 'Button Text', default: 'Donate Now' },
showAmounts: { type: 'boolean', label: 'Show Suggested Amounts', default: true },
heading: { type: 'string', label: 'Heading', default: 'Support Our Cause' },
description: { type: 'string', label: 'Description' },
},
defaults: {
buttonText: 'Donate Now',
showAmounts: true,
heading: 'Support Our Cause',
description: 'Your contribution helps us create lasting change in our community.',
},
},
{
id: 'default-pricing-table',
type: 'pricing-table',
label: 'Pricing Table',
category: 'Payments',
sortOrder: 10,
schema: {
showYearly: { type: 'boolean', label: 'Show Yearly Toggle', default: true },
heading: { type: 'string', label: 'Heading', default: 'Choose Your Plan' },
description: { type: 'string', label: 'Description' },
},
defaults: {
showYearly: true,
heading: 'Choose Your Plan',
description: 'Get access to exclusive content and features.',
},
},
{
id: 'default-product-card',
type: 'product-card',
label: 'Product Card',
category: 'Payments',
sortOrder: 11,
schema: {
productSlug: { type: 'string', label: 'Product Slug', required: true },
buttonText: { type: 'string', label: 'Button Text', default: 'Buy Now' },
},
defaults: {
productSlug: '',
buttonText: 'Buy Now',
},
},
];
for (const block of defaultBlocks) {
@@ -258,6 +330,9 @@ async function main() {
console.warn('⚠️ No admin user found - skipping email template seeding');
}
// Seed pre-made gallery ads (all inactive by default — admin enables manually)
await seedGalleryAds();
console.log('Seed complete.');
}
@@ -404,6 +479,58 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 4 },
],
},
{
key: 'donation-receipt',
name: 'Donation Receipt',
description: 'Receipt email sent to donors after a successful donation payment',
category: EmailTemplateCategory.PAYMENT,
subjectLine: 'Donation Receipt — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'Donor Name', description: 'Name of the donor', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'AMOUNT', label: 'Amount', description: 'Donation amount formatted with dollar sign', isRequired: true, isConditional: false, sampleValue: '$25.00', sortOrder: 1 },
{ key: 'ORDER_ID', label: 'Order ID', description: 'Unique reference ID for the donation', isRequired: true, isConditional: false, sampleValue: 'clxyz123abc', sortOrder: 2 },
{ key: 'DONATION_DATE', label: 'Donation Date', description: 'Date the donation was completed', isRequired: true, isConditional: false, sampleValue: 'February 17, 2026', sortOrder: 3 },
{ key: 'DONOR_MESSAGE', label: 'Donor Message', description: 'Optional message from the donor', isRequired: false, isConditional: true, sampleValue: 'Keep up the great work!', sortOrder: 4 },
{ key: 'IS_ANONYMOUS', label: 'Is Anonymous', description: 'Whether the donation is anonymous', isRequired: false, isConditional: true, sampleValue: 'true', sortOrder: 5 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 6 },
],
},
{
key: 'product-receipt',
name: 'Product Purchase Receipt',
description: 'Receipt email sent to buyers after a successful product purchase',
category: EmailTemplateCategory.PAYMENT,
subjectLine: 'Purchase Receipt — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'Buyer Name', description: 'Name of the buyer', isRequired: true, isConditional: false, sampleValue: 'John Smith', sortOrder: 0 },
{ key: 'AMOUNT', label: 'Amount', description: 'Purchase amount formatted with dollar sign', isRequired: true, isConditional: false, sampleValue: '$49.99', sortOrder: 1 },
{ key: 'ORDER_ID', label: 'Order ID', description: 'Unique order reference ID', isRequired: true, isConditional: false, sampleValue: 'clxyz456def', sortOrder: 2 },
{ key: 'PRODUCT_TITLE', label: 'Product Title', description: 'Title of the purchased product', isRequired: true, isConditional: false, sampleValue: 'Community Toolkit', sortOrder: 3 },
{ key: 'PRODUCT_TYPE', label: 'Product Type', description: 'Type of product (DIGITAL, EVENT, DONATION)', isRequired: true, isConditional: false, sampleValue: 'DIGITAL', sortOrder: 4 },
{ key: 'PURCHASE_DATE', label: 'Purchase Date', description: 'Date of purchase', isRequired: true, isConditional: false, sampleValue: 'February 17, 2026', sortOrder: 5 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 6 },
],
},
{
key: 'subscription-welcome',
name: 'Subscription Welcome',
description: 'Welcome email sent when a user subscribes to a plan',
category: EmailTemplateCategory.PAYMENT,
subjectLine: 'Welcome to {{PLAN_NAME}} — {{ORGANIZATION_NAME}}',
isSystem: true,
variables: [
{ key: 'RECIPIENT_NAME', label: 'User Name', description: 'Name of the subscriber', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 0 },
{ key: 'PLAN_NAME', label: 'Plan Name', description: 'Name of the subscription plan', isRequired: true, isConditional: false, sampleValue: 'Pro Plan', sortOrder: 1 },
{ key: 'AMOUNT', label: 'Amount', description: 'Subscription price formatted with dollar sign', isRequired: true, isConditional: false, sampleValue: '$9.99', sortOrder: 2 },
{ key: 'FREQUENCY', label: 'Billing Frequency', description: 'How often the subscription renews', isRequired: true, isConditional: false, sampleValue: 'per month', sortOrder: 3 },
{ key: 'RENEWAL_DATE', label: 'Renewal Date', description: 'Next renewal date', isRequired: true, isConditional: false, sampleValue: 'March 17, 2026', sortOrder: 4 },
{ key: 'SUBSCRIPTION_ID', label: 'Subscription ID', description: 'Stripe subscription ID', isRequired: true, isConditional: false, sampleValue: 'sub_1234567890', sortOrder: 5 },
{ key: 'LOGIN_URL', label: 'Login URL', description: 'URL to log in to the platform', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/login', sortOrder: 6 },
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 7 },
],
},
];
let seededCount = 0;
@@ -476,6 +603,131 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
console.log(`Email templates seeded: ${seededCount} created, ${skippedCount} skipped`);
}
/**
* Seed pre-made gallery ads
*/
async function seedGalleryAds() {
console.log('Seeding gallery ads...');
const defaultAds = [
{
type: 'system',
variant: 'standard',
title: 'Join the Community',
subtitle: 'Create a free account to upvote, comment, and save favorites',
ctaText: 'Sign Up Free',
ctaStyle: 'primary',
linkUrl: '/login',
visibility: 'anonymous',
frequency: 8,
position: 1,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'payment_subscribe',
variant: 'highlight',
title: 'Unlock Premium Content',
subtitle: 'Subscribe for exclusive videos, early access, and more',
ctaText: 'View Plans',
ctaStyle: 'primary',
linkUrl: '/pricing',
visibility: 'non_subscriber',
frequency: 12,
position: 2,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'payment_donate',
variant: 'standard',
title: 'Support Our Mission',
subtitle: 'Your donation helps us create more content',
ctaText: 'Donate Now',
ctaStyle: 'primary',
linkUrl: '/donate',
visibility: 'everyone',
frequency: 18,
position: 3,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'payment_shop',
variant: 'standard',
title: 'Browse the Shop',
subtitle: 'Exclusive merchandise, downloads, and event tickets',
ctaText: 'Shop Now',
ctaStyle: 'primary',
linkUrl: '/shop',
visibility: 'everyone',
frequency: 24,
position: 4,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'system',
variant: 'standard',
title: 'Take Action',
subtitle: 'Join an advocacy campaign and make your voice heard',
ctaText: 'View Campaigns',
ctaStyle: 'primary',
linkUrl: '/campaigns',
visibility: 'everyone',
frequency: 18,
position: 5,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
{
type: 'system',
variant: 'standard',
title: 'Volunteer With Us',
subtitle: 'Sign up for a shift and help make a difference',
ctaText: 'See Shifts',
ctaStyle: 'primary',
linkUrl: '/shifts',
visibility: 'everyone',
frequency: 24,
position: 6,
iconEmoji: null,
bgColor: null,
imagePath: null,
},
];
let seeded = 0;
let skipped = 0;
for (const ad of defaultAds) {
const existing = await prisma.ad.findFirst({
where: { type: ad.type, title: ad.title },
});
if (existing) {
skipped++;
continue;
}
await prisma.ad.create({
data: {
...ad,
isSystemAd: true,
isActive: false,
},
});
seeded++;
}
console.log(`Gallery ads seeded: ${seeded} created, ${skipped} skipped`);
}
main()
.catch((e) => {
console.error('Seed error:', e);