Conversation
- 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
…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); |
There was a problem hiding this comment.
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-expiredpage 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'] }); |
There was a problem hiding this comment.
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.
| return manager.findOneOrFail(DRALeadGenerator, { where: { id }, relations: ['leads'] }); | |
| return manager.findOneOrFail(DRALeadGenerator, { where: { id } }); |
| // 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); |
There was a problem hiding this comment.
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).
| // 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); | |
| } | |
| }); |
| // 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); |
There was a problem hiding this comment.
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).
| // 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; |
There was a problem hiding this comment.
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.
| <script setup> | ||
| definePageMeta({ layout: 'default' }); | ||
| const config = useRuntimeConfig(); | ||
| const route = useRoute(); | ||
| const slug = computed(() => route.params.slug); | ||
|
|
There was a problem hiding this comment.
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.
| 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', | ||
| }); |
There was a problem hiding this comment.
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.
| <script setup> | ||
| definePageMeta({ layout: 'default' }); | ||
| const { $swal } = useNuxtApp(); | ||
| const config = useRuntimeConfig(); | ||
|
|
||
| const state = reactive({ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
this is not needed
| <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); |
There was a problem hiding this comment.
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.
| <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); |
There was a problem hiding this comment.
This is not needed at the moment.
| <script setup> | ||
| definePageMeta({ layout: 'default' }); | ||
| const { $swal } = useNuxtApp(); | ||
| const config = useRuntimeConfig(); | ||
| const router = useRouter(); | ||
|
|
||
| const state = reactive({ | ||
| submitting: false, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
This is not needed at the moment.
| validate([ | ||
| param('id').notEmpty().toInt(), | ||
| body('title').optional().trim(), | ||
| body('slug').optional().trim(), |
There was a problem hiding this comment.
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”).
| body('slug').optional().trim(), | |
| body('slug').optional().trim().notEmpty().withMessage('Slug cannot be blank'), |
…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
Description
feat: add lead generator PDF system with gated/open download support
Type of Change
Please delete options that are not relevant:
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.
Checklist
Please check all the boxes that apply: