Skip to content

DRA-283-Lead-Generator-PDF-System#375

Open
mustafaneguib wants to merge 8 commits intomainfrom
DRA-283-Lead-Generator-PDF-System
Open

DRA-283-Lead-Generator-PDF-System#375
mustafaneguib wants to merge 8 commits intomainfrom
DRA-283-Lead-Generator-PDF-System

Conversation

@mustafaneguib
Copy link
Copy Markdown
Member

Description

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

Type of Change

Please delete options that are not relevant:

  • 🐛 Bug fix
  • ✨ New feature
  • 🛠 Refactor (non-breaking change, code improvements)
  • 📚 Documentation update
  • 🔥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • ✅ Tests (adding or updating tests)

How Has This Been Tested?

Please describe the tests that you ran to verify your changes.
Provide instructions so we can reproduce and validate the behavior.

  • Unit Tests
  • Integration Tests
  • Manual Testing

Checklist

Please check all the boxes that apply:

  • I have read the CONTRIBUTING.md guidelines.
  • My code follows the code style of this project.
  • [] I have added necessary tests.
  • I have updated the documentation (if needed).
  • My changes generate no new warnings or errors.
  • I have linked the related issue(s) in the description.

- 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
@mustafaneguib mustafaneguib self-assigned this Apr 19, 2026
Copilot AI review requested due to automatic review settings April 19, 2026 08:22
Comment thread backend/src/routes/admin/lead-generators.ts Fixed
Comment thread backend/src/routes/admin/lead-generators.ts Fixed
Comment thread backend/src/routes/admin/lead-generators.ts Fixed
Comment thread backend/src/routes/admin/lead-generators.ts Fixed
mustafaneguib and others added 2 commits April 19, 2026 13:26
…ed in path expression'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
…ed in path expression'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
const relative = path.relative(uploadRoot, filePath);
const isWithinUploadDir = relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));

if (isWithinUploadDir && fs.existsSync(filePath)) {
const isWithinUploadDir = relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));

if (isWithinUploadDir && fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
const isWithinUploadDir =
filePath === uploadRoot || filePath.startsWith(uploadRoot + path.sep);

if (isWithinUploadDir && fs.existsSync(filePath)) fs.unlinkSync(filePath);
const isWithinUploadDir =
filePath === uploadRoot || filePath.startsWith(uploadRoot + path.sep);

if (isWithinUploadDir && fs.existsSync(filePath)) fs.unlinkSync(filePath);
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new “Lead Generator PDF” system to support public resource landing pages with open vs gated downloads, lead capture, and an admin UI for managing PDFs and viewing analytics/leads.

Changes:

  • Backend: new LeadGenerator models + migration, processor for CRUD/analytics, public + admin route modules, and an EmailService template for gated-download emails.
  • Frontend: public /resources/[slug] landing page (SSR metadata fetch + view tracking + gated form) and /resources/download-expired page for expired/used tokens.
  • Frontend admin: lead generator list/create/edit+analytics pages, plus middleware + sidebar updates to surface the new section.

Reviewed changes

Copilot reviewed 18 out of 19 changed files in this pull request and generated 14 comments.

Show a summary per file
File Description
frontend/pages/resources/download-expired.vue Public UX page for expired/used download tokens.
frontend/pages/resources/[slug].vue Public resource landing page with SSR metadata fetch, view tracking, open/gated download flows.
frontend/pages/admin/lead-generators/index.vue Admin list page with active toggle and delete flow.
frontend/pages/admin/lead-generators/create.vue Admin create page with auto-slug + drag/drop PDF upload.
frontend/pages/admin/lead-generators/[id]/index.vue Admin edit + analytics/leads tab, CSV export.
frontend/middleware/02-load-data.global.ts Marks /resources routes as public (skip load-data behavior).
frontend/middleware/01-authorization.global.ts Marks /resources/* as public (skip auth redirect).
frontend/components/sidebar-admin.vue Adds Lead Generators section to admin navigation.
backend/src/services/EmailService.ts Adds sendLeadGeneratorEmail() for queued delivery of one-time download links.
backend/src/routes/lead-generators.ts Public lead generator routes: metadata, view tracking, open file download, gate + token download.
backend/src/routes/admin/lead-generators.ts Admin CRUD + lead listing routes (file upload via multer).
backend/src/processors/LeadGeneratorProcessor.ts Encapsulates lead generator CRUD, counters, lead capture, file path helpers.
backend/src/models/DRALeadGenerator.ts TypeORM entity for lead generator PDFs.
backend/src/models/DRALeadGeneratorLead.ts TypeORM entity for captured lead submissions.
backend/src/migrations/1775900000000-CreateLeadGeneratorTables.ts Creates dra_lead_generators + dra_lead_generator_leads tables and FK/index.
backend/src/index.ts Mounts new public/admin lead-generator routes; ensures private upload dir exists.
backend/src/datasources/PostgresDataSource.ts Registers new entities in the main Postgres datasource.
backend/src/datasources/PostgresDSMigrations.ts Registers new entities in the migrations datasource.
.gitignore Ignores backend/private/* (uploaded lead generator PDFs).


async getLeadGeneratorById(id: number): Promise<DRALeadGenerator> {
const manager = await this.getManager();
return manager.findOneOrFail(DRALeadGenerator, { where: { id }, relations: ['leads'] });
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getLeadGeneratorById() always loads relations: ['leads']. This will cause the admin “get one” endpoint to return (and the server to load) all captured leads, which can get very large and is redundant with the paginated /leads endpoint. Consider removing the relation load here (or adding a separate method for analytics/leads) and keep the default fetch lightweight.

Suggested change
return manager.findOneOrFail(DRALeadGenerator, { where: { id }, relations: ['leads'] });
return manager.findOneOrFail(DRALeadGenerator, { where: { id } });

Copilot uses AI. Check for mistakes.
Comment thread backend/src/routes/lead-generators.ts Outdated
Comment on lines +50 to +63
// 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);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one-time token is deleted from Redis before confirming the file can be served. If the file is missing or streaming fails, the user cannot retry with the same link. Consider deferring redis.del() until after the file path is validated (and ideally after the stream opens), and also consider enforcing is_active here (tokens currently allow downloads even if the resource is unpublished).

Suggested change
// 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);
const lg = await processor.getLeadGeneratorById(parseInt(leadGeneratorId, 10));
if ('is_active' in lg && !lg.is_active) {
return res.redirect(expiredRedirect);
}
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(expiredRedirect);
}
const fileStream = fs.createReadStream(filePath);
fileStream.once('error', (streamError: Error) => {
console.error('[lead-generators/download] stream error:', streamError);
if (!res.headersSent) {
return res.redirect(expiredRedirect);
}
res.destroy(streamError);
});
fileStream.once('open', async () => {
try {
await redis.del(redisKey);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(lg.original_file_name)}"`);
fileStream.pipe(res);
} catch (redisError) {
console.error('[lead-generators/download] failed to delete download token:', redisError);
fileStream.destroy();
if (!res.headersSent) {
return res.redirect(expiredRedirect);
}
res.destroy(redisError as Error);
}
});

Copilot uses AI. Check for mistakes.
Comment thread backend/src/routes/lead-generators.ts Outdated
Comment on lines +193 to +201
// 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);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Download tracking for gated PDFs is incremented on gate form submission, but the actual token-based download endpoint does not increment download_count. This will over-count downloads when users submit but don’t download, and under-count downloads when email tokens are used later. Move the increment to the /download/:token handler (and only increment on successful file delivery).

Copilot uses AI. Check for mistakes.
Comment on lines +151 to +227
// 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;
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR introduces new public/admin lead-generator routes with one-time Redis tokens, email dispatch, and file streaming, but there are no new backend tests added. The repo has extensive Jest coverage under backend/src/__tests__; consider adding integration tests covering: admin RBAC for these routes, gate submission issuing 2 tokens, one-time token invalidation, and expired-token redirects.

Copilot uses AI. Check for mistakes.
Comment thread frontend/pages/resources/[slug].vue Outdated
Comment on lines +1 to +6
<script setup>
definePageMeta({ layout: 'default' });
const config = useRuntimeConfig();
const route = useRoute();
const slug = computed(() => route.params.slug);

Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most pages in this repo use TypeScript SFCs (<script setup lang="ts">). This new page is missing lang="ts", which loses type-checking for route params, $fetch responses, and form state. Consider converting to TypeScript and typing the form state / API response shape.

Copilot uses AI. Check for mistakes.
Comment on lines +58 to +67
const result = await ($swal).fire({
icon: 'warning',
title: 'Delete Lead Generator',
html: `<p>Are you sure you want to delete <strong>${lg.title}</strong>?</p><p class="text-sm text-gray-500 mt-2">This will permanently delete the PDF file and all captured leads.</p>`,
showCancelButton: true,
confirmButtonText: 'Yes, Delete',
cancelButtonText: 'Cancel',
confirmButtonColor: '#dc2626',
cancelButtonColor: '#6b7280',
});
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SweetAlert confirmation uses the html option and interpolates lg.title directly into HTML. Since titles are user-controlled (even if admin-created), this can allow XSS in the admin UI. Prefer using text (no HTML) or escape/sanitize the title before injecting into html.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +6
<script setup>
definePageMeta({ layout: 'default' });
const { $swal } = useNuxtApp();
const config = useRuntimeConfig();

const state = reactive({
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most pages in this repo use TypeScript SFCs (<script setup lang="ts">, e.g. frontend/pages/admin/platform-settings.vue:1). These new admin pages use plain <script setup> with no types, which reduces type-safety and makes refactors riskier. Consider switching to lang="ts" and adding minimal interfaces for API responses/state where appropriate.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not needed

Comment on lines +1 to +5
<script setup>
definePageMeta({ layout: 'default' });
const route = useRoute();
const slug = computed(() => typeof route.query.slug === 'string' ? route.query.slug : null);
const resourceUrl = computed(() => slug.value ? `/resources/${slug.value}` : null);
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most pages in this repo use TypeScript SFCs (<script setup lang="ts">). This new page is missing lang="ts", which loses type-checking for route query parsing and computed values. Consider switching to TypeScript for consistency and safer refactors.

Suggested change
<script setup>
definePageMeta({ layout: 'default' });
const route = useRoute();
const slug = computed(() => typeof route.query.slug === 'string' ? route.query.slug : null);
const resourceUrl = computed(() => slug.value ? `/resources/${slug.value}` : null);
<script setup lang="ts">
definePageMeta({ layout: 'default' });
const route = useRoute();
const slug = computed<string | null>(() => typeof route.query.slug === 'string' ? route.query.slug : null);
const resourceUrl = computed<string | null>(() => slug.value ? `/resources/${slug.value}` : null);

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not needed at the moment.

Comment on lines +1 to +8
<script setup>
definePageMeta({ layout: 'default' });
const { $swal } = useNuxtApp();
const config = useRuntimeConfig();
const router = useRouter();

const state = reactive({
submitting: false,
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most pages in this repo use TypeScript SFCs (<script setup lang="ts">). This new page is missing lang="ts", which loses type-checking for form state, route params, and API response parsing. Consider converting to TypeScript and typing key request/response objects.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not needed at the moment.

validate([
param('id').notEmpty().toInt(),
body('title').optional().trim(),
body('slug').optional().trim(),
Copy link

Copilot AI Apr 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same slug validation concern on update: body('slug').optional().trim() permits setting slug to an empty string. This can break routing and violate the unique index. Tighten validation to reject blank slugs (or treat blank as “don’t change”).

Suggested change
body('slug').optional().trim(),
body('slug').optional().trim().notEmpty().withMessage('Slug cannot be blank'),

Copilot uses AI. Check for mistakes.
…view comments

- Add requireAdmin middleware to all admin/lead-generators routes to
  enforce admin-only access (was validateJWT-only, no role check)
- Tighten slug validation on POST /add and PUT /:id: reject empty
  strings and enforce kebab-case format via regex
- Fix path traversal (CodeQL CWE-022): sanitise file_name with
  path.basename() before joining with upload dir in getFilePath()
- Remove eager loading of leads relation from getLeadGeneratorById()
  to avoid fetching all leads on every detail/download request
- Move incrementDownloadCount() from POST /:slug/gate (lead capture)
  to GET /download/:token (actual file delivery) so the count reflects
  real downloads, not form submissions
- Defer redis.del() until after file existence is confirmed; add
  stream error handler to createReadStream pipe
- Replace SweetAlert html: option (XSS risk) with text: in the admin
  delete confirmation dialog
…erfaces

Add missing type definitions for the lead generator feature:
- types/ILeadGenerator.ts — interface for lead generator entities
- types/ILeadGeneratorLead.ts — interface for individual lead records
…ng="ts"

Add lang="ts" to script setup blocks in app.vue and both layout files
to enforce TypeScript checking at the layout level.
…ed state and props

- Add lang="ts" to all <script setup> blocks
- Add typed interfaces for reactive() state objects
- Add TypeScript types to function parameters and return values
- Type ref() declarations with proper generics (HTMLElement | null, etc.)
- Type all defineProps() and defineEmits() signatures
- Use `any` for opaque API shapes and third-party object boundaries

Files: HeroCarousel, RowLimitWarning, SQLErrorAlert, SubscriptionUsageWidget,
add-external-data-source, EditSubscriptionTierModal, SubscriptionTierForm,
ai-showcase, anomaly-alert-card-widget, breadcrumbs, campaign-timeline-widget,
all chart components (bubble, donut, funnel, horizontal-bar, multi-line, pie,
stacked-bar, table, treemap, vertical-bar), combo-button, community,
custom-data-table, data-model-builder, faq-section, font-awesome, footer-nav,
header-nav, hero, how-do-it-get-started, menu-dropdown, multi-select,
navigation-drawer, notched-card, overlay-dialog, partner-trust-badges,
problems, sidebar-admin, sidebar, spinner, switch-button, testComp,
text-editor, why-dra
…and function params

- Add lang="ts" to all <script setup> blocks across frontend/pages/
- Replace all reactive({}) with reactive<Interface>({}) using named interfaces
- Add TypeScript types to all function parameters
- Type ref(null) DOM element refs with proper generics
- Type aiWidgetDates and aiWidgetState as Record<string, any>
- Extract inline `as` type casts into proper interface definitions
  (BillingData in org settings, PromoCode in pricing.vue)
- Use `any` for complex API response shapes and third-party objects
- All event handlers typed as (e: Event), (e: MouseEvent), (e: DragEvent)
  with (e.target as HTMLInputElement).value for input access

Files: all pages under admin/, articles/, billing/, connect/, dashboards/,
data-models/, data-sources/, forgot-password/, insights/, invitations/,
login, marketing-projects/, notifications/, oauth/, organization-invitations/,
pricing, projects/, public-dashboard/, register, resources/, settings/,
unsubscribe/, verify-email/, and root-level pages
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants