From 96f3f143b639cfba135aa0a9b7f659df3da29933 Mon Sep 17 00:00:00 2001 From: Mustafa Neguib Date: Sun, 19 Apr 2026 13:21:46 +0500 Subject: [PATCH 1/9] feat: add lead generator PDF system with gated/open download support - Add migration, TypeORM models (DRALeadGenerator, DRALeadGeneratorLead) - Add LeadGeneratorProcessor with CRUD, view/download tracking, lead capture - Add EmailService.sendLeadGeneratorEmail() with queued delivery - Add admin routes (GET /list, POST /add, GET/:id, PUT/:id, DELETE/:id, GET/:id/leads) - Add public routes (GET/:slug, POST/:slug/view, GET/:slug/file, POST/:slug/gate, GET/download/:token) - Issue two separate one-time Redis tokens per gate submission (frontend + email) - Mount both route files in index.ts; create private/lead-generators/ dir at startup - Add Lead Generators section to sidebar-admin - Add admin list page with active toggle, delete confirm, stats columns - Add admin create page with auto-slug generation, gated toggle, drag-and-drop PDF upload - Add admin edit/analytics page with two tabs: edit form and stats/leads table with CSV export - Add public resource landing page with SSR data fetch, view tracking, open download and gated form - Add download-expired page; backend redirects there (with ?slug=) on expired/used tokens - Fix drag-and-drop: move dragover/drop handlers to outer div to prevent browser hijacking the drop - Fix SSR hydration: use useAsyncData returned data ref directly instead of a separate ref - Register /resources/* as public routes in both auth and load-data middleware --- .gitignore | 1 + .../src/datasources/PostgresDSMigrations.ts | 5 +- backend/src/datasources/PostgresDataSource.ts | 7 +- backend/src/index.ts | 8 + ...1775900000000-CreateLeadGeneratorTables.ts | 64 +++ backend/src/models/DRALeadGenerator.ts | 51 ++ backend/src/models/DRALeadGeneratorLead.ts | 44 ++ .../src/processors/LeadGeneratorProcessor.ts | 210 ++++++++ backend/src/routes/admin/lead-generators.ts | 194 ++++++++ backend/src/routes/lead-generators.ts | 227 +++++++++ backend/src/services/EmailService.ts | 77 +++ frontend/components/sidebar-admin.vue | 9 + .../middleware/01-authorization.global.ts | 1 + frontend/middleware/02-load-data.global.ts | 3 +- .../admin/lead-generators/[id]/index.vue | 460 ++++++++++++++++++ .../pages/admin/lead-generators/create.vue | 250 ++++++++++ .../pages/admin/lead-generators/index.vue | 203 ++++++++ frontend/pages/resources/[slug].vue | 266 ++++++++++ frontend/pages/resources/download-expired.vue | 35 ++ 19 files changed, 2110 insertions(+), 5 deletions(-) create mode 100644 backend/src/migrations/1775900000000-CreateLeadGeneratorTables.ts create mode 100644 backend/src/models/DRALeadGenerator.ts create mode 100644 backend/src/models/DRALeadGeneratorLead.ts create mode 100644 backend/src/processors/LeadGeneratorProcessor.ts create mode 100644 backend/src/routes/admin/lead-generators.ts create mode 100644 backend/src/routes/lead-generators.ts create mode 100644 frontend/pages/admin/lead-generators/[id]/index.vue create mode 100644 frontend/pages/admin/lead-generators/create.vue create mode 100644 frontend/pages/admin/lead-generators/index.vue create mode 100644 frontend/pages/resources/[slug].vue create mode 100644 frontend/pages/resources/download-expired.vue diff --git a/.gitignore b/.gitignore index 30d028ee..a9587512 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ backend/dist backend/public/uploads/* !backend/public/uploads backend/backend/* +backend/private/* #---------- # Frontend diff --git a/backend/src/datasources/PostgresDSMigrations.ts b/backend/src/datasources/PostgresDSMigrations.ts index d48481e8..80f51474 100644 --- a/backend/src/datasources/PostgresDSMigrations.ts +++ b/backend/src/datasources/PostgresDSMigrations.ts @@ -50,6 +50,8 @@ import { DRADowngradeRequest } from '../models/DRADowngradeRequest.js'; import { DRAPromoCode } from '../models/DRAPromoCode.js'; import { DRAPromoCodeRedemption } from '../models/DRAPromoCodeRedemption.js'; import { DRAPaymentTransaction } from '../models/DRAPaymentTransaction.js'; +import { DRALeadGenerator } from '../models/DRALeadGenerator.js'; +import { DRALeadGeneratorLead } from '../models/DRALeadGeneratorLead.js'; import dotenv from 'dotenv'; dotenv.config(); @@ -69,7 +71,8 @@ export default new DataSource({ database: database, synchronize: false, logging: true, - entities: [DRAUsersPlatform, DRAProject, DRAProjectMember, DRAProjectInvitation, DRAVerificationCode, DRADataSource, DRADataModel, DRADataModelLineage, DRADataModelSource, DRATableMetadata, DRACrossSourceJoinCatalog, DRAEnterpriseQuery, DRADashboard, DRAArticle, DRAArticleCategory, DRAArticleVersion, DRACategory, DRASitemapEntry, DRADashboardExportMetaData, DRAAIDataModelConversation, DRAAIDataModelMessage, DRAAIInsightReport, DRAAIInsightMessage, DRADataModelRefreshHistory, DRAScheduledBackupRun, DRASubscriptionTier, DRAPlatformSettings, DRAAccountCancellation, DRAEmailPreferences, DRANotification, DRAMongoDBSyncHistory, SyncHistory, DRACampaign, DRACampaignChannel, DRACampaignOfflineData, DRAAIJoinSuggestion, DRAReport, DRAReportItem, DRAReportShareKey, DRAOrganization, DRAWorkspace, DRAOrganizationMember, DRAOrganizationInvitation, DRAWorkspaceMember, DRAOrganizationSubscription, DRAPaddleWebhookEvent, DRAEnterpriseContactRequest, DRADowngradeRequest, DRAPromoCode, DRAPromoCodeRedemption, DRAPaymentTransaction], + entities: [DRAUsersPlatform, DRAProject, DRAProjectMember, DRAProjectInvitation, DRAVerificationCode, DRADataSource, DRADataModel, DRADataModelLineage, DRADataModelSource, DRATableMetadata, DRACrossSourceJoinCatalog, DRAEnterpriseQuery, DRADashboard, DRAArticle, DRAArticleCategory, DRAArticleVersion, DRACategory, DRASitemapEntry, DRADashboardExportMetaData, DRAAIDataModelConversation, DRAAIDataModelMessage, DRAAIInsightReport, DRAAIInsightMessage, DRADataModelRefreshHistory, DRAScheduledBackupRun, DRASubscriptionTier, DRAPlatformSettings, DRAAccountCancellation, DRAEmailPreferences, DRANotification, DRAMongoDBSyncHistory, SyncHistory, DRACampaign, DRACampaignChannel, DRACampaignOfflineData, DRAAIJoinSuggestion, DRAReport, DRAReportItem, DRAReportShareKey, DRAOrganization, DRAWorkspace, DRAOrganizationMember, DRAOrganizationInvitation, DRAWorkspaceMember, DRAOrganizationSubscription, DRAPaddleWebhookEvent, DRAEnterpriseContactRequest, DRADowngradeRequest, DRAPromoCode, DRAPromoCodeRedemption, DRAPaymentTransaction, + DRALeadGenerator, DRALeadGeneratorLead], subscribers: [], migrations: ['./src/migrations/*.ts'], }); \ No newline at end of file diff --git a/backend/src/datasources/PostgresDataSource.ts b/backend/src/datasources/PostgresDataSource.ts index de26564d..051def6d 100644 --- a/backend/src/datasources/PostgresDataSource.ts +++ b/backend/src/datasources/PostgresDataSource.ts @@ -49,8 +49,8 @@ import { DRAEnterpriseContactRequest } from "../models/DRAEnterpriseContactReque import { DRADowngradeRequest } from "../models/DRADowngradeRequest.js"; import { DRAPromoCode } from "../models/DRAPromoCode.js"; import { DRAPromoCodeRedemption } from "../models/DRAPromoCodeRedemption.js"; -import { DRAPaymentTransaction } from "../models/DRAPaymentTransaction.js"; -import dotenv from 'dotenv'; +import { DRAPaymentTransaction } from "../models/DRAPaymentTransaction.js";import { DRALeadGenerator } from '../models/DRALeadGenerator.js'; +import { DRALeadGeneratorLead } from '../models/DRALeadGeneratorLead.js';import dotenv from 'dotenv'; dotenv.config(); export class PostgresDataSource { @@ -73,7 +73,8 @@ export class PostgresDataSource { database: database, synchronize: false, logging: true, - entities: [DRAUsersPlatform, DRAProject, DRAVerificationCode, DRADataSource, DRADataModel, DRADataModelLineage, DRADataModelSource, DRATableMetadata, DRACrossSourceJoinCatalog, DRAEnterpriseQuery, DRADashboard, DRAArticle, DRAArticleCategory, DRAArticleVersion, DRACategory, DRASitemapEntry, DRADashboardExportMetaData, DRAAIDataModelConversation, DRAAIDataModelMessage, DRAAIInsightReport, DRAAIInsightMessage, DRADataModelRefreshHistory, DRAScheduledBackupRun, DRASubscriptionTier, DRAProjectMember, DRAProjectInvitation, DRANotification, DRAPlatformSettings, DRAAccountCancellation, DRAEmailPreferences, DRAMongoDBSyncHistory, SyncHistory, DRACampaign, DRACampaignChannel, DRAAIJoinSuggestion, DRAReport, DRAReportItem, DRAReportShareKey, DRAOrganization, DRAWorkspace, DRAOrganizationMember, DRAOrganizationInvitation, DRAWorkspaceMember, DRAOrganizationSubscription, DRACampaignOfflineData, DRAPaddleWebhookEvent, DRAEnterpriseContactRequest, DRADowngradeRequest, DRAPromoCode, DRAPromoCodeRedemption, DRAPaymentTransaction], + entities: [DRAUsersPlatform, DRAProject, DRAVerificationCode, DRADataSource, DRADataModel, DRADataModelLineage, DRADataModelSource, DRATableMetadata, DRACrossSourceJoinCatalog, DRAEnterpriseQuery, DRADashboard, DRAArticle, DRAArticleCategory, DRAArticleVersion, DRACategory, DRASitemapEntry, DRADashboardExportMetaData, DRAAIDataModelConversation, DRAAIDataModelMessage, DRAAIInsightReport, DRAAIInsightMessage, DRADataModelRefreshHistory, DRAScheduledBackupRun, DRASubscriptionTier, DRAProjectMember, DRAProjectInvitation, DRANotification, DRAPlatformSettings, DRAAccountCancellation, DRAEmailPreferences, DRAMongoDBSyncHistory, SyncHistory, DRACampaign, DRACampaignChannel, DRAAIJoinSuggestion, DRAReport, DRAReportItem, DRAReportShareKey, DRAOrganization, DRAWorkspace, DRAOrganizationMember, DRAOrganizationInvitation, DRAWorkspaceMember, DRAOrganizationSubscription, DRACampaignOfflineData, DRAPaddleWebhookEvent, DRAEnterpriseContactRequest, DRADowngradeRequest, DRAPromoCode, DRAPromoCodeRedemption, DRAPaymentTransaction, + DRALeadGenerator, DRALeadGeneratorLead], subscribers: [], // Only load TypeORM migration files (exclude utility scripts like migrate-articles-markdown.ts) migrations: ['./src/migrations/*.ts'], diff --git a/backend/src/index.ts b/backend/src/index.ts index 1aed0dfd..50e97f42 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -71,6 +71,8 @@ import offlineTracking from './routes/offlineTracking.js'; import reports from './routes/reports.js'; import marketing from './routes/marketing.js'; import paddle_webhook from './routes/paddle-webhook.js'; +import lead_generators from './routes/lead-generators.js'; +import admin_lead_generators from './routes/admin/lead-generators.js'; import path from 'path'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; @@ -322,6 +324,12 @@ app.use('/campaigns', offlineTracking); app.use('/reports', reports); app.use('/marketing', marketing); app.use('/paddle', paddle_webhook); +app.use('/lead-generators', lead_generators); +app.use('/admin/lead-generators', admin_lead_generators); + +// Ensure private upload directories exist +import fs from 'fs'; +fs.mkdirSync(path.join(__dirname, '../private/lead-generators'), { recursive: true }); app.use('/uploads', express.static(path.join(__dirname, '../public/uploads'))); app.use('/', express.static(path.join(__dirname, '../public'))); diff --git a/backend/src/migrations/1775900000000-CreateLeadGeneratorTables.ts b/backend/src/migrations/1775900000000-CreateLeadGeneratorTables.ts new file mode 100644 index 00000000..6cb27216 --- /dev/null +++ b/backend/src/migrations/1775900000000-CreateLeadGeneratorTables.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateLeadGeneratorTables1775900000000 implements MigrationInterface { + name = 'CreateLeadGeneratorTables1775900000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "dra_lead_generators" ( + "id" SERIAL NOT NULL, + "title" character varying(255) NOT NULL, + "slug" character varying(255) NOT NULL, + "description" text, + "file_name" character varying(500) NOT NULL, + "original_file_name" character varying(500) NOT NULL, + "is_gated" boolean NOT NULL DEFAULT true, + "is_active" boolean NOT NULL DEFAULT true, + "view_count" integer NOT NULL DEFAULT 0, + "download_count" integer NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_dra_lead_generators" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX IF NOT EXISTS "UQ_dra_lead_generators_slug" + ON "dra_lead_generators" ("slug") + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "dra_lead_generator_leads" ( + "id" SERIAL NOT NULL, + "lead_generator_id" integer NOT NULL, + "full_name" character varying(255), + "email" character varying(255) NOT NULL, + "company" character varying(255), + "phone" character varying(50), + "job_title" character varying(255), + "ip_address" character varying(45), + "created_at" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "PK_dra_lead_generator_leads" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query(` + DO $$ BEGIN + ALTER TABLE "dra_lead_generator_leads" + ADD CONSTRAINT "FK_dra_lead_generator_leads_generator" + FOREIGN KEY ("lead_generator_id") + REFERENCES "dra_lead_generators"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION; + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "dra_lead_generator_leads"`); + await queryRunner.query(`DROP INDEX IF EXISTS "UQ_dra_lead_generators_slug"`); + await queryRunner.query(`DROP TABLE IF EXISTS "dra_lead_generators"`); + } +} diff --git a/backend/src/models/DRALeadGenerator.ts b/backend/src/models/DRALeadGenerator.ts new file mode 100644 index 00000000..a2b2e3fe --- /dev/null +++ b/backend/src/models/DRALeadGenerator.ts @@ -0,0 +1,51 @@ +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { DRALeadGeneratorLead } from './DRALeadGeneratorLead.js'; + +@Entity('dra_lead_generators') +export class DRALeadGenerator { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'varchar', length: 255 }) + title!: string; + + @Column({ type: 'varchar', length: 255, unique: true }) + slug!: string; + + @Column({ type: 'text', nullable: true }) + description!: string | null; + + @Column({ type: 'varchar', length: 500 }) + file_name!: string; + + @Column({ type: 'varchar', length: 500 }) + original_file_name!: string; + + @Column({ type: 'boolean', default: true }) + is_gated!: boolean; + + @Column({ type: 'boolean', default: true }) + is_active!: boolean; + + @Column({ type: 'int', default: 0 }) + view_count!: number; + + @Column({ type: 'int', default: 0 }) + download_count!: number; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at!: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updated_at!: Date; + + @OneToMany(() => DRALeadGeneratorLead, (lead) => lead.lead_generator) + leads!: DRALeadGeneratorLead[]; +} diff --git a/backend/src/models/DRALeadGeneratorLead.ts b/backend/src/models/DRALeadGeneratorLead.ts new file mode 100644 index 00000000..dbcb2ffc --- /dev/null +++ b/backend/src/models/DRALeadGeneratorLead.ts @@ -0,0 +1,44 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, +} from 'typeorm'; +import { DRALeadGenerator } from './DRALeadGenerator.js'; + +@Entity('dra_lead_generator_leads') +export class DRALeadGeneratorLead { + @PrimaryGeneratedColumn() + id!: number; + + @Column({ type: 'int' }) + lead_generator_id!: number; + + @Column({ type: 'varchar', length: 255, nullable: true }) + full_name!: string | null; + + @Column({ type: 'varchar', length: 255 }) + email!: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + company!: string | null; + + @Column({ type: 'varchar', length: 50, nullable: true }) + phone!: string | null; + + @Column({ type: 'varchar', length: 255, nullable: true }) + job_title!: string | null; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ip_address!: string | null; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at!: Date; + + @ManyToOne(() => DRALeadGenerator, (lg) => lg.leads, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'lead_generator_id' }) + lead_generator!: Relation; +} diff --git a/backend/src/processors/LeadGeneratorProcessor.ts b/backend/src/processors/LeadGeneratorProcessor.ts new file mode 100644 index 00000000..b3932007 --- /dev/null +++ b/backend/src/processors/LeadGeneratorProcessor.ts @@ -0,0 +1,210 @@ +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { DBDriver } from '../drivers/DBDriver.js'; +import { EDataSourceType } from '../types/EDataSourceType.js'; +import { DRALeadGenerator } from '../models/DRALeadGenerator.js'; +import { DRALeadGeneratorLead } from '../models/DRALeadGeneratorLead.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export class LeadGeneratorProcessor { + private static instance: LeadGeneratorProcessor; + + private constructor() {} + + public static getInstance(): LeadGeneratorProcessor { + if (!LeadGeneratorProcessor.instance) { + LeadGeneratorProcessor.instance = new LeadGeneratorProcessor(); + } + return LeadGeneratorProcessor.instance; + } + + // ---------------------------------------------------------------- + // Private helpers + // ---------------------------------------------------------------- + + private getFilePath(fileName: string): string { + return path.join(__dirname, '../../private/lead-generators', fileName); + } + + private generateSlug(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .trim() + .replace(/[\s]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, ''); + } + + private async getManager() { + const driver = await DBDriver.getInstance().getDriver(EDataSourceType.POSTGRESQL); + if (!driver) throw new Error('Database driver not available'); + return (await driver.getConcreteDriver()).manager; + } + + // ---------------------------------------------------------------- + // Admin CRUD + // ---------------------------------------------------------------- + + async createLeadGenerator(params: { + title: string; + slug?: string; + description?: string; + fileName: string; + originalFileName: string; + isGated: boolean; + }): Promise { + const manager = await this.getManager(); + const lg = manager.create(DRALeadGenerator, { + title: params.title, + slug: params.slug || this.generateSlug(params.title), + description: params.description || null, + file_name: params.fileName, + original_file_name: params.originalFileName, + is_gated: params.isGated, + is_active: true, + view_count: 0, + download_count: 0, + }); + return manager.save(lg); + } + + async updateLeadGenerator( + id: number, + params: Partial<{ + title: string; + slug: string; + description: string | null; + isGated: boolean; + isActive: boolean; + fileName: string; + originalFileName: string; + }> + ): Promise { + const manager = await this.getManager(); + const lg = await manager.findOneOrFail(DRALeadGenerator, { where: { id } }); + + if (params.title !== undefined) lg.title = params.title; + if (params.slug !== undefined) lg.slug = params.slug; + if (params.description !== undefined) lg.description = params.description; + if (params.isGated !== undefined) lg.is_gated = params.isGated; + if (params.isActive !== undefined) lg.is_active = params.isActive; + if (params.fileName !== undefined) { + // Delete old file from disk before replacing + const oldPath = this.getFilePath(lg.file_name); + if (fs.existsSync(oldPath)) { + try { fs.unlinkSync(oldPath); } catch (e) { console.warn('[LeadGeneratorProcessor] Could not delete old PDF:', e); } + } + lg.file_name = params.fileName; + lg.original_file_name = params.originalFileName ?? lg.original_file_name; + } + + return manager.save(lg); + } + + async deleteLeadGenerator(id: number): Promise { + const manager = await this.getManager(); + const lg = await manager.findOneOrFail(DRALeadGenerator, { where: { id } }); + + // Delete physical file from disk + const filePath = this.getFilePath(lg.file_name); + if (fs.existsSync(filePath)) { + try { fs.unlinkSync(filePath); } catch (e) { console.warn('[LeadGeneratorProcessor] Could not delete PDF file:', e); } + } + + await manager.remove(lg); + } + + async getAllLeadGenerators(): Promise<(DRALeadGenerator & { lead_count: number })[]> { + const manager = await this.getManager(); + const results = await manager + .createQueryBuilder(DRALeadGenerator, 'lg') + .loadRelationCountAndMap('lg.lead_count', 'lg.leads') + .orderBy('lg.created_at', 'DESC') + .getMany(); + return results as (DRALeadGenerator & { lead_count: number })[]; + } + + async getLeadGeneratorById(id: number): Promise { + const manager = await this.getManager(); + return manager.findOneOrFail(DRALeadGenerator, { where: { id }, relations: ['leads'] }); + } + + async getLeadsForGenerator( + id: number, + page = 1, + limit = 50 + ): Promise<{ leads: DRALeadGeneratorLead[]; total: number }> { + const manager = await this.getManager(); + const [leads, total] = await manager.findAndCount(DRALeadGeneratorLead, { + where: { lead_generator_id: id }, + order: { created_at: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + return { leads, total }; + } + + // ---------------------------------------------------------------- + // Public-facing + // ---------------------------------------------------------------- + + async getBySlug(slug: string): Promise { + const manager = await this.getManager(); + return manager.findOne(DRALeadGenerator, { where: { slug, is_active: true } }); + } + + async incrementViewCount(id: number): Promise { + const manager = await this.getManager(); + await manager + .createQueryBuilder() + .update(DRALeadGenerator) + .set({ view_count: () => 'view_count + 1' }) + .where('id = :id', { id }) + .execute(); + } + + async incrementDownloadCount(id: number): Promise { + const manager = await this.getManager(); + await manager + .createQueryBuilder() + .update(DRALeadGenerator) + .set({ download_count: () => 'download_count + 1' }) + .where('id = :id', { id }) + .execute(); + } + + async recordLead(params: { + leadGeneratorId: number; + email: string; + fullName?: string; + company?: string; + phone?: string; + jobTitle?: string; + ipAddress?: string; + }): Promise { + const manager = await this.getManager(); + const lead = manager.create(DRALeadGeneratorLead, { + lead_generator_id: params.leadGeneratorId, + email: params.email, + full_name: params.fullName || null, + company: params.company || null, + phone: params.phone || null, + job_title: params.jobTitle || null, + ip_address: params.ipAddress || null, + }); + return manager.save(lead); + } + + // ---------------------------------------------------------------- + // Utility + // ---------------------------------------------------------------- + + getFilePathPublic(fileName: string): string { + return this.getFilePath(fileName); + } +} diff --git a/backend/src/routes/admin/lead-generators.ts b/backend/src/routes/admin/lead-generators.ts new file mode 100644 index 00000000..86e75942 --- /dev/null +++ b/backend/src/routes/admin/lead-generators.ts @@ -0,0 +1,194 @@ +import express, { Request, Response } from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { validateJWT } from '../../middleware/authenticate.js'; +import { validate } from '../../middleware/validator.js'; +import { body, matchedData, param, query } from 'express-validator'; +import { LeadGeneratorProcessor } from '../../processors/LeadGeneratorProcessor.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const router = express.Router(); +const processor = LeadGeneratorProcessor.getInstance(); + +const uploadDir = path.join(__dirname, '../../../private/lead-generators'); +fs.mkdirSync(uploadDir, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => { + fs.mkdirSync(uploadDir, { recursive: true }); + cb(null, uploadDir); + }, + filename: (_req, file, cb) => { + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); + cb(null, uniqueSuffix + path.extname(file.originalname)); + }, +}); + +const upload = multer({ + storage, + fileFilter: (_req, file, cb) => { + if (file.mimetype !== 'application/pdf') { + return cb(new Error('Only PDF files are allowed')); + } + cb(null, true); + }, + limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB +}); + +// GET /admin/lead-generators — list all +router.get( + '/list', + validateJWT, + async (req: Request, res: Response) => { + try { + const leadGenerators = await processor.getAllLeadGenerators(); + res.status(200).json({ success: true, data: leadGenerators }); + } catch (error: any) { + console.error('[admin/lead-generators] list error:', error); + res.status(500).json({ success: false, error: error.message }); + } + } +); + +// POST /admin/lead-generators/add — create +router.post( + '/add', + validateJWT, + upload.single('pdf'), + validate([ + body('title').notEmpty().trim(), + body('slug').optional().trim(), + body('description').optional().trim(), + body('isGated').optional().isBoolean().toBoolean(), + ]), + async (req: Request, res: Response) => { + try { + if (!req.file) { + return res.status(400).json({ success: false, error: 'PDF file is required' }); + } + const data = matchedData(req); + const leadGenerator = await processor.createLeadGenerator({ + title: data.title, + slug: data.slug || undefined, + description: data.description || undefined, + fileName: req.file.filename, + originalFileName: req.file.originalname, + isGated: data.isGated !== undefined ? data.isGated : true, + }); + res.status(200).json({ success: true, data: leadGenerator }); + } catch (error: any) { + // Clean up uploaded file if DB save failed + if (req.file) { + const filePath = path.join(uploadDir, req.file.filename); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } + console.error('[admin/lead-generators] add error:', error); + res.status(500).json({ success: false, error: error.message }); + } + } +); + +// GET /admin/lead-generators/:id — get one +router.get( + '/:id', + validateJWT, + validate([param('id').notEmpty().toInt()]), + async (req: Request, res: Response) => { + try { + const { id } = matchedData(req); + const leadGenerator = await processor.getLeadGeneratorById(id); + res.status(200).json({ success: true, data: leadGenerator }); + } catch (error: any) { + console.error('[admin/lead-generators] get error:', error); + res.status(404).json({ success: false, error: 'Lead generator not found' }); + } + } +); + +// PUT /admin/lead-generators/:id — update +router.put( + '/:id', + validateJWT, + upload.single('pdf'), + validate([ + param('id').notEmpty().toInt(), + body('title').optional().trim(), + body('slug').optional().trim(), + body('description').optional().trim(), + body('isGated').optional().isBoolean().toBoolean(), + body('isActive').optional().isBoolean().toBoolean(), + ]), + async (req: Request, res: Response) => { + try { + const data = matchedData(req); + const updateParams: Parameters[1] = {}; + + if (data.title !== undefined) updateParams.title = data.title; + if (data.slug !== undefined) updateParams.slug = data.slug; + if (data.description !== undefined) updateParams.description = data.description || null; + if (data.isGated !== undefined) updateParams.isGated = data.isGated; + if (data.isActive !== undefined) updateParams.isActive = data.isActive; + if (req.file) { + updateParams.fileName = req.file.filename; + updateParams.originalFileName = req.file.originalname; + } + + const updated = await processor.updateLeadGenerator(data.id, updateParams); + res.status(200).json({ success: true, data: updated }); + } catch (error: any) { + if (req.file) { + const filePath = path.join(uploadDir, req.file.filename); + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + } + console.error('[admin/lead-generators] update error:', error); + res.status(500).json({ success: false, error: error.message }); + } + } +); + +// DELETE /admin/lead-generators/:id — delete +router.delete( + '/:id', + validateJWT, + validate([param('id').notEmpty().toInt()]), + async (req: Request, res: Response) => { + try { + const { id } = matchedData(req); + await processor.deleteLeadGenerator(id); + res.status(200).json({ success: true, message: 'Lead generator deleted successfully' }); + } catch (error: any) { + console.error('[admin/lead-generators] delete error:', error); + res.status(500).json({ success: false, error: error.message }); + } + } +); + +// GET /admin/lead-generators/:id/leads — list leads (paginated) +router.get( + '/:id/leads', + validateJWT, + validate([ + param('id').notEmpty().toInt(), + query('page').optional().toInt(), + query('limit').optional().toInt(), + ]), + async (req: Request, res: Response) => { + try { + const data = matchedData(req); + const page = data.page || 1; + const limit = Math.min(data.limit || 50, 200); + const result = await processor.getLeadsForGenerator(data.id, page, limit); + res.status(200).json({ success: true, ...result, page, limit }); + } catch (error: any) { + console.error('[admin/lead-generators] leads error:', error); + res.status(500).json({ success: false, error: error.message }); + } + } +); + +export default router; diff --git a/backend/src/routes/lead-generators.ts b/backend/src/routes/lead-generators.ts new file mode 100644 index 00000000..66b9ee4c --- /dev/null +++ b/backend/src/routes/lead-generators.ts @@ -0,0 +1,227 @@ +import express, { Request, Response } from 'express'; +import path from 'path'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { randomUUID } from 'crypto'; +import { validate } from '../middleware/validator.js'; +import { param, body } from 'express-validator'; +import { matchedData } from 'express-validator'; +import { LeadGeneratorProcessor } from '../processors/LeadGeneratorProcessor.js'; +import { EmailService } from '../services/EmailService.js'; +import { getRedisClient } from '../config/redis.config.js'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const router = express.Router(); +const processor = LeadGeneratorProcessor.getInstance(); + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const DOWNLOAD_TOKEN_TTL = 3600; // 1 hour in seconds +const DOWNLOAD_TOKEN_PREFIX = 'lgdl:'; + +const getFrontendUrl = () => process.env.FRONTEND_URL || process.env.SOCKETIO_CLIENT_URL || 'http://localhost:3000'; + +// GET /lead-generators/download/:token — one-time token file delivery +// IMPORTANT: defined BEFORE /:slug to prevent '/download' being captured as a slug +router.get( + '/download/:token', + async (req: Request, res: Response) => { + try { + const token = req.params.token; + const slug = typeof req.query.slug === 'string' ? req.query.slug : ''; + const expiredRedirect = slug + ? `${getFrontendUrl()}/resources/download-expired?slug=${encodeURIComponent(slug)}` + : `${getFrontendUrl()}/resources/download-expired`; + + if (!token) { + return res.redirect(expiredRedirect); + } + + const redisKey = `${DOWNLOAD_TOKEN_PREFIX}${token}`; + const redis = getRedisClient(); + const leadGeneratorId = await redis.get(redisKey); + + if (!leadGeneratorId) { + return res.redirect(expiredRedirect); + } + + // Delete token immediately (one-time use) + await redis.del(redisKey); + + const lg = await processor.getLeadGeneratorById(parseInt(leadGeneratorId, 10)); + const filePath = processor.getFilePathPublic(lg.file_name); + + if (!fs.existsSync(filePath)) { + console.error('[lead-generators/download] file not found on disk:', filePath); + return res.redirect(`${getFrontendUrl()}/resources/download-expired`); + } + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(lg.original_file_name)}"`); + fs.createReadStream(filePath).pipe(res); + } catch (error: any) { + console.error('[lead-generators/download] error:', error); + return res.redirect(`${getFrontendUrl()}/resources/download-expired`); + } + } +); + +// GET /lead-generators/:slug — public metadata for landing page +router.get( + '/:slug', + validate([param('slug').notEmpty().trim()]), + async (req: Request, res: Response) => { + try { + const { slug } = matchedData(req); + const lg = await processor.getBySlug(slug); + if (!lg) { + return res.status(404).json({ success: false, error: 'Not found' }); + } + res.status(200).json({ + success: true, + data: { + id: lg.id, + title: lg.title, + slug: lg.slug, + description: lg.description, + is_gated: lg.is_gated, + }, + }); + } catch (error: any) { + console.error('[lead-generators] get by slug error:', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + } + } +); + +// POST /lead-generators/:slug/view — fire-and-forget view tracking +router.post( + '/:slug/view', + async (req: Request, res: Response) => { + res.status(200).json({ success: true }); + // Async, non-blocking — errors are intentionally swallowed + try { + const slug = req.params.slug; + if (!slug) return; + const lg = await processor.getBySlug(slug); + if (lg) { + await processor.incrementViewCount(lg.id); + } + } catch (e) { + // Silently ignore — view tracking must not block the response + } + } +); + +// GET /lead-generators/:slug/file — open PDFs only +router.get( + '/:slug/file', + validate([param('slug').notEmpty().trim()]), + async (req: Request, res: Response) => { + try { + const { slug } = matchedData(req); + const lg = await processor.getBySlug(slug); + if (!lg) { + return res.status(404).json({ success: false, error: 'Not found' }); + } + if (lg.is_gated) { + return res.status(403).json({ success: false, error: 'This resource requires registration' }); + } + + const filePath = processor.getFilePathPublic(lg.file_name); + if (!fs.existsSync(filePath)) { + console.error('[lead-generators] file not found on disk:', filePath); + return res.status(404).json({ success: false, error: 'File not found' }); + } + + await processor.incrementDownloadCount(lg.id); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(lg.original_file_name)}"`); + fs.createReadStream(filePath).pipe(res); + } catch (error: any) { + console.error('[lead-generators] file download error:', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + } + } +); + +// POST /lead-generators/:slug/gate — gated PDFs: capture lead, issue token, send email +router.post( + '/:slug/gate', + validate([ + param('slug').notEmpty().trim(), + body('email').notEmpty().trim().isEmail(), + body('fullName').optional().trim(), + body('company').optional().trim(), + body('phone').optional().trim(), + body('jobTitle').optional().trim(), + ]), + async (req: Request, res: Response) => { + try { + const data = matchedData(req); + const { slug, email, fullName, company, phone, jobTitle } = data; + + if (!EMAIL_REGEX.test(email)) { + return res.status(400).json({ success: false, error: 'Invalid email address' }); + } + + const lg = await processor.getBySlug(slug); + if (!lg) { + return res.status(404).json({ success: false, error: 'Not found' }); + } + if (!lg.is_gated) { + return res.status(400).json({ success: false, error: 'This resource is not gated' }); + } + + // Get real client IP (respects trust proxy setting in Express) + const ipAddress = (req.ip || req.socket.remoteAddress || '').replace(/^::ffff:/, ''); + + // Record lead + await processor.recordLead({ + leadGeneratorId: lg.id, + email, + fullName: fullName || undefined, + company: company || undefined, + phone: phone || undefined, + jobTitle: jobTitle || undefined, + ipAddress: ipAddress || undefined, + }); + + // Increment download count + await processor.incrementDownloadCount(lg.id); + + // Generate two one-time Redis download tokens: one for the immediate frontend download, one for the email link + const frontendToken = randomUUID(); + const emailToken = randomUUID(); + const redis = getRedisClient(); + await redis.set(`${DOWNLOAD_TOKEN_PREFIX}${frontendToken}`, String(lg.id), 'EX', DOWNLOAD_TOKEN_TTL); + await redis.set(`${DOWNLOAD_TOKEN_PREFIX}${emailToken}`, String(lg.id), 'EX', DOWNLOAD_TOKEN_TTL); + + // Build email download URL using the email-specific token; include slug so expired page can link back to the resource + const backendUrl = process.env.BACKEND_URL || 'http://localhost:3002'; + const downloadUrl = `${backendUrl}/lead-generators/download/${emailToken}?slug=${encodeURIComponent(lg.slug)}`; + + // Send email (queued, non-blocking on error) + try { + await EmailService.getInstance().sendLeadGeneratorEmail({ + toEmail: email, + recipientName: fullName || '', + pdfTitle: lg.title, + downloadUrl, + }); + } catch (emailErr) { + console.error('[lead-generators] email send error (non-fatal):', emailErr); + } + + res.status(200).json({ success: true, downloadToken: frontendToken }); + } catch (error: any) { + console.error('[lead-generators] gate error:', error); + res.status(500).json({ success: false, error: 'Internal server error' }); + } + } +); + +export default router; diff --git a/backend/src/services/EmailService.ts b/backend/src/services/EmailService.ts index ede7d1c7..1a124de1 100644 --- a/backend/src/services/EmailService.ts +++ b/backend/src/services/EmailService.ts @@ -2320,4 +2320,81 @@ ${platformUrl}`; this.queueProcessor = null; } } + + /** + * Send lead generator download email + * + * Sent after a visitor completes the gate form on a gated PDF. + * Includes a one-time download link (valid 1 hour). + * + * @param params.toEmail - Recipient email address + * @param params.recipientName - Visitor's name (or empty string for anonymous) + * @param params.pdfTitle - Display title of the lead generator PDF + * @param params.downloadUrl - One-time backend download URL (expires in 1 hour) + */ + public async sendLeadGeneratorEmail(params: { + toEmail: string; + recipientName: string; + pdfTitle: string; + downloadUrl: string; + }): Promise { + const { toEmail, recipientName, pdfTitle, downloadUrl } = params; + const firstName = recipientName?.split(' ')[0] || 'there'; + const frontendUrl = UtilityService.getInstance().getConstants('FRONTEND_URL') || 'http://localhost:3000'; + const supportEmail = process.env.MAIL_REPLY_TO || 'support@dataresearchanalysis.com'; + + const html = ` + +Your Download Is Ready + + + +
+ + + + +
+

Your Download Is Ready

+
+

Hi ${this.sanitizeForSubject(firstName)},

+

+ Thank you for your interest! Your copy of ${this.sanitizeForSubject(pdfTitle)} is ready to download. +

+ + +
+ + ⬇ Download Your PDF + +
+

+ ⏱ This link expires in 1 hour. If it has expired, you can return to the resource page and request a new link. +

+

+ If you have any questions, reply to this email or contact us at ${supportEmail}. +

+
+

+ © ${new Date().getFullYear()} Data Research Analysis +

+
+
+ +`; + + const text = `Hi ${firstName}, + +Thank you for your interest! Your copy of "${pdfTitle}" is ready to download. + +Download link (expires in 1 hour): +${downloadUrl} + +If the link has expired, visit the resource page to request a new one. + +Questions? Email us at ${supportEmail}`; + + await this.sendEmail({ to: toEmail, subject: `Your download is ready: ${this.sanitizeForSubject(pdfTitle)}`, html, text }); + } } diff --git a/frontend/components/sidebar-admin.vue b/frontend/components/sidebar-admin.vue index ebeaedc9..7d29e2c9 100644 --- a/frontend/components/sidebar-admin.vue +++ b/frontend/components/sidebar-admin.vue @@ -56,6 +56,15 @@ const state = reactive({ { id: 3, name: 'List Categories', path: '/admin/articles/categories' }, ], }, + { + id: 11, + menu_name: 'Lead Generators', + show_menu: true, + sub_menus: [ + { id: 1, name: 'Add Lead Generator', path: '/admin/lead-generators/create' }, + { id: 2, name: 'List Lead Generators', path: '/admin/lead-generators' }, + ], + }, { id: 7, menu_name: 'Sitemap Manager', diff --git a/frontend/middleware/01-authorization.global.ts b/frontend/middleware/01-authorization.global.ts index b60e93cc..12124682 100644 --- a/frontend/middleware/01-authorization.global.ts +++ b/frontend/middleware/01-authorization.global.ts @@ -50,6 +50,7 @@ function isPublicRoute(path: string): boolean { /^\/connect\/.+$/, // /connect/[provider] — OAuth callback landing pages /^\/oauth\/.+$/, // /oauth/[provider]/callback — Google OAuth callback /^\/organization-invitations\/accept\/.+$/, // /organization-invitations/accept/[token] — Public invitation view page + /^\/resources(\/.*)?$/, // /resources and /resources/[slug] — public lead generator pages ]; // Check exact matches diff --git a/frontend/middleware/02-load-data.global.ts b/frontend/middleware/02-load-data.global.ts index 780f9fce..bcee59a5 100644 --- a/frontend/middleware/02-load-data.global.ts +++ b/frontend/middleware/02-load-data.global.ts @@ -160,7 +160,8 @@ function isPublicRoute(path: string): boolean { path.startsWith('/public-dashboard') || path.startsWith('/verify-email') || path.startsWith('/forgot-password') || - path.startsWith('/unsubscribe'); + path.startsWith('/unsubscribe') || + path.startsWith('/resources'); } /** diff --git a/frontend/pages/admin/lead-generators/[id]/index.vue b/frontend/pages/admin/lead-generators/[id]/index.vue new file mode 100644 index 00000000..2da84248 --- /dev/null +++ b/frontend/pages/admin/lead-generators/[id]/index.vue @@ -0,0 +1,460 @@ + + +