feat: Add Google Analytics integration and fix anonymous tracking

- Add Google Analytics tracking (G-ZDZ26XYN2P) to frontend
- Create comprehensive analytics utility with event tracking
- Track URL submissions, analysis results, and user authentication
- Add route tracking for SPA navigation
- Fix CORS configuration to support both localhost and production
- Fix home page tracking form to display results instead of auto-redirect
- Add service management scripts for easier deployment
- Update database migrations for enhanced analysis features

Key Features:
- Anonymous and authenticated user tracking
- SSL/SEO/Security analysis event tracking
- Error tracking for debugging
- Page view tracking for SPA routes
- Multi-origin CORS support for development and production
This commit is contained in:
Andrei
2025-08-23 17:45:01 +00:00
parent 58f8093689
commit 6e41d9874d
19 changed files with 1904 additions and 95 deletions

View File

@@ -20,6 +20,7 @@ import trackingRoutes from './routes/tracking.routes';
import analysisRoutes from './routes/analysis.routes';
import exportRoutes from './routes/export.routes';
import bulkRoutes from './routes/bulk.routes';
import projectsRoutes from './routes/projects.routes';
import docsRoutes from './routes/docs.routes';
import { legacyRateLimit, requestLogger, rateLimitErrorHandler } from './middleware/rate-limit.middleware';
@@ -45,8 +46,23 @@ app.use(compression());
app.use(requestLogger({ redactionLevel: 'partial' }));
// CORS middleware
const allowedOrigins = [
'http://localhost:3000',
'https://urltrackertool.com',
process.env.CORS_ORIGIN
].filter(Boolean);
app.use(cors({
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
origin: (origin, callback) => {
// Allow requests with no origin (like mobile apps or curl requests)
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) {
return callback(null, true);
}
return callback(new Error('Not allowed by CORS'));
},
credentials: true,
optionsSuccessStatus: 200 // Some legacy browsers (IE11, various SmartTVs) choke on 204
}));
@@ -84,12 +100,14 @@ app.use('/v2/analyze', analysisRoutes);
// Export routes (v2)
app.use('/v2/export', exportRoutes);
app.use('/v2/bulk', bulkRoutes);
app.use('/v2/projects', projectsRoutes);
// Backward compatibility: keep /api/v2 routes as well
app.use('/api/v2', trackingRoutes);
app.use('/api/v2/analyze', analysisRoutes);
app.use('/api/v2/export', exportRoutes);
app.use('/api/v2/bulk', bulkRoutes);
app.use('/api/v2/projects', projectsRoutes);
// Documentation routes
app.use('/', docsRoutes);

View File

@@ -0,0 +1,446 @@
/**
* Project Management Routes for URL Tracker Tool V2
*
* Handles project creation, management, and organization
*/
import express from 'express';
import { z } from 'zod';
import { requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware';
import { PrismaClient } from '@prisma/client';
import { logger } from '../lib/logger';
const router = express.Router();
const prisma = new PrismaClient();
// Validation schemas
const createProjectSchema = z.object({
name: z.string().min(1, 'Project name is required').max(100, 'Project name too long'),
description: z.string().optional(),
settings: z.object({
defaultMethod: z.enum(['GET', 'POST', 'HEAD']).default('GET'),
defaultTimeout: z.number().min(1000).max(30000).default(15000),
defaultMaxHops: z.number().min(1).max(20).default(10),
enableSSLAnalysis: z.boolean().default(true),
enableSEOAnalysis: z.boolean().default(true),
enableSecurityAnalysis: z.boolean().default(true),
}).optional(),
});
const updateProjectSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().optional(),
settings: z.object({
defaultMethod: z.enum(['GET', 'POST', 'HEAD']).optional(),
defaultTimeout: z.number().min(1000).max(30000).optional(),
defaultMaxHops: z.number().min(1).max(20).optional(),
enableSSLAnalysis: z.boolean().optional(),
enableSEOAnalysis: z.boolean().optional(),
enableSecurityAnalysis: z.boolean().optional(),
}).optional(),
});
const projectParamsSchema = z.object({
projectId: z.string().min(1),
});
/**
* GET /api/v2/projects
* Get user's projects
*/
router.get('/', requireAuth, async (req: AuthenticatedRequest, res) => {
try {
const user = req.user!;
const orgId = user.memberships[0]?.orgId;
if (!orgId) {
return res.status(404).json({
success: false,
error: 'No organization found',
message: 'User must belong to an organization to access projects'
});
}
const projects = await prisma.project.findMany({
where: {
orgId: orgId,
},
include: {
_count: {
select: {
checks: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
});
res.json({
success: true,
data: {
projects: projects.map(project => ({
id: project.id,
name: project.name,
description: (project.settingsJson as any)?.description || null,
settings: project.settingsJson,
trackingCount: project._count.checks,
createdAt: project.createdAt,
})),
},
});
} catch (error) {
logger.error('Failed to get projects:', error);
res.status(500).json({
success: false,
error: 'Failed to get projects',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* POST /api/v2/projects
* Create a new project
*/
router.post('/', requireAuth, async (req: AuthenticatedRequest, res) => {
try {
const user = req.user!;
const orgId = user.memberships[0]?.orgId;
if (!orgId) {
return res.status(404).json({
success: false,
error: 'No organization found',
message: 'User must belong to an organization to create projects'
});
}
// Validate input
const validatedData = createProjectSchema.parse(req.body);
// Create project
const project = await prisma.project.create({
data: {
name: validatedData.name,
orgId: orgId,
settingsJson: {
description: validatedData.description || '',
...validatedData.settings,
},
},
});
logger.info(`Project created: ${project.id}`, {
userId: user.id,
orgId,
projectName: project.name,
});
res.status(201).json({
success: true,
data: {
project: {
id: project.id,
name: project.name,
description: validatedData.description || null,
settings: project.settingsJson,
trackingCount: 0,
createdAt: project.createdAt,
},
},
message: 'Project created successfully',
});
} catch (error) {
logger.error('Failed to create project:', error);
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: 'Validation failed',
details: error.errors,
});
}
res.status(500).json({
success: false,
error: 'Failed to create project',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* GET /api/v2/projects/:projectId
* Get project details
*/
router.get('/:projectId', requireAuth, async (req: AuthenticatedRequest, res) => {
try {
const user = req.user!;
const { projectId } = projectParamsSchema.parse(req.params);
const orgId = user.memberships[0]?.orgId;
if (!orgId) {
return res.status(404).json({
success: false,
error: 'No organization found',
});
}
const project = await prisma.project.findFirst({
where: {
id: projectId,
orgId: orgId,
},
include: {
_count: {
select: {
checks: true,
},
},
checks: {
take: 10,
orderBy: {
startedAt: 'desc',
},
include: {
hops: {
select: {
url: true,
statusCode: true,
redirectType: true,
},
},
},
},
},
});
if (!project) {
return res.status(404).json({
success: false,
error: 'Project not found',
message: 'Project does not exist or you do not have access to it',
});
}
res.json({
success: true,
data: {
project: {
id: project.id,
name: project.name,
description: (project.settingsJson as any)?.description || null,
settings: project.settingsJson,
trackingCount: project._count.checks,
recentChecks: project.checks.map(check => ({
id: check.id,
inputUrl: check.inputUrl,
finalUrl: check.finalUrl,
status: check.status,
startedAt: check.startedAt,
hopCount: check.hops.length,
})),
createdAt: project.createdAt,
},
},
});
} catch (error) {
logger.error('Failed to get project:', error);
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: 'Invalid project ID',
});
}
res.status(500).json({
success: false,
error: 'Failed to get project',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* PUT /api/v2/projects/:projectId
* Update project
*/
router.put('/:projectId', requireAuth, async (req: AuthenticatedRequest, res) => {
try {
const user = req.user!;
const { projectId } = projectParamsSchema.parse(req.params);
const orgId = user.memberships[0]?.orgId;
if (!orgId) {
return res.status(404).json({
success: false,
error: 'No organization found',
});
}
// Validate input
const validatedData = updateProjectSchema.parse(req.body);
// Check if project exists and user has access
const existingProject = await prisma.project.findFirst({
where: {
id: projectId,
orgId: orgId,
},
});
if (!existingProject) {
return res.status(404).json({
success: false,
error: 'Project not found',
message: 'Project does not exist or you do not have access to it',
});
}
// Update project
const currentSettings = existingProject.settingsJson as any || {};
const project = await prisma.project.update({
where: {
id: projectId,
},
data: {
...(validatedData.name && { name: validatedData.name }),
settingsJson: {
...currentSettings,
...(validatedData.description !== undefined && { description: validatedData.description }),
...validatedData.settings,
},
},
});
logger.info(`Project updated: ${project.id}`, {
userId: user.id,
orgId,
changes: validatedData,
});
res.json({
success: true,
data: {
project: {
id: project.id,
name: project.name,
description: (project.settingsJson as any)?.description || null,
settings: project.settingsJson,
createdAt: project.createdAt,
},
},
message: 'Project updated successfully',
});
} catch (error) {
logger.error('Failed to update project:', error);
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: 'Validation failed',
details: error.errors,
});
}
res.status(500).json({
success: false,
error: 'Failed to update project',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
/**
* DELETE /api/v2/projects/:projectId
* Delete project
*/
router.delete('/:projectId', requireAuth, async (req: AuthenticatedRequest, res) => {
try {
const user = req.user!;
const { projectId } = projectParamsSchema.parse(req.params);
const orgId = user.memberships[0]?.orgId;
if (!orgId) {
return res.status(404).json({
success: false,
error: 'No organization found',
});
}
// Check if project exists and user has access
const existingProject = await prisma.project.findFirst({
where: {
id: projectId,
orgId: orgId,
},
include: {
_count: {
select: {
checks: true,
},
},
},
});
if (!existingProject) {
return res.status(404).json({
success: false,
error: 'Project not found',
message: 'Project does not exist or you do not have access to it',
});
}
// Prevent deletion of projects with tracking data
if (existingProject._count.checks > 0) {
return res.status(400).json({
success: false,
error: 'Cannot delete project with tracking data',
message: `This project has ${existingProject._count.checks} tracking records. Please archive or move the data first.`,
});
}
// Delete project
await prisma.project.delete({
where: {
id: projectId,
},
});
logger.info(`Project deleted: ${projectId}`, {
userId: user.id,
orgId,
projectName: existingProject.name,
});
res.json({
success: true,
message: 'Project deleted successfully',
});
} catch (error) {
logger.error('Failed to delete project:', error);
if (error instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: 'Invalid project ID',
});
}
res.status(500).json({
success: false,
error: 'Failed to delete project',
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
export default router;

View File

@@ -8,6 +8,7 @@ import express from 'express';
import { z } from 'zod';
import rateLimit from 'express-rate-limit';
import { RedirectTrackerService } from '../services/redirect-tracker.service';
import { prisma } from '../lib/prisma';
import { optionalAuth, requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware';
import { trackingRateLimit, addRateLimitStatus } from '../middleware/rate-limit.middleware';
import { logger } from '../lib/logger';
@@ -103,14 +104,65 @@ router.post('/track',
// Set project ID based on authentication status
if (!validatedData.projectId) {
if (req.user) {
// Authenticated user - use their default project
// Authenticated user - get their default project
const userMembership = req.user.memberships[0];
if (userMembership) {
validatedData.projectId = 'default-project'; // Placeholder
// Find the user's default project
const defaultProject = await prisma.project.findFirst({
where: {
orgId: userMembership.orgId
}
});
if (defaultProject) {
validatedData.projectId = defaultProject.id;
} else {
// Create a default project if none exists
const newProject = await prisma.project.create({
data: {
name: 'Default Project',
orgId: userMembership.orgId,
settingsJson: {}
}
});
validatedData.projectId = newProject.id;
}
}
} else {
// Anonymous user - use the anonymous project
validatedData.projectId = 'anonymous-project';
// Anonymous user - create or find anonymous project
let anonymousProject = await prisma.project.findFirst({
where: {
name: 'Anonymous Tracking'
}
});
if (!anonymousProject) {
// Need to create anonymous organization first
let anonymousOrg = await prisma.organization.findFirst({
where: {
name: 'Anonymous Users'
}
});
if (!anonymousOrg) {
anonymousOrg = await prisma.organization.create({
data: {
name: 'Anonymous Users',
plan: 'free'
}
});
}
anonymousProject = await prisma.project.create({
data: {
name: 'Anonymous Tracking',
orgId: anonymousOrg.id,
settingsJson: {}
}
});
}
validatedData.projectId = anonymousProject.id;
}
}

View File

@@ -26,9 +26,9 @@ const trackRequestSchema = z.object({
followJS: z.boolean().default(false), // Future: JavaScript redirects
maxHops: z.number().min(1).max(20).default(10),
timeout: z.number().min(1000).max(30000).default(15000),
enableSSLAnalysis: z.boolean().default(false),
enableSEOAnalysis: z.boolean().default(false),
enableSecurityAnalysis: z.boolean().default(false),
enableSSLAnalysis: z.boolean().default(true),
enableSEOAnalysis: z.boolean().default(true),
enableSecurityAnalysis: z.boolean().default(true),
});
export type TrackRequest = z.infer<typeof trackRequestSchema>;
@@ -59,6 +59,11 @@ export interface CheckResult {
redirectCount: number;
loopDetected?: boolean;
error?: string;
analysis?: {
ssl?: any;
seo?: any;
security?: any;
};
}
/**
@@ -149,6 +154,9 @@ export class RedirectTrackerService {
// Perform enhanced analysis on final URL if enabled
await this.performEnhancedAnalysis(check.id, finalUrl || inputUrl, hops.map(h => h.url), validatedRequest);
// Fetch analysis results from database
const analysisData = await this.getAnalysisResults(check.id);
const result: CheckResult = {
id: check.id,
inputUrl,
@@ -171,6 +179,7 @@ export class RedirectTrackerService {
})),
redirectCount,
loopDetected,
analysis: analysisData,
};
logger.info(`Enhanced redirect tracking completed: ${inputUrl}`, {
@@ -608,4 +617,42 @@ export class RedirectTrackerService {
// Don't throw - analysis failure shouldn't break the main tracking
}
}
/**
* Fetch analysis results from database for a check
*/
private async getAnalysisResults(checkId: string): Promise<any> {
try {
const [sslInspections, seoFlags, securityFlags] = await Promise.all([
prisma.sslInspection.findMany({
where: { checkId }
}),
prisma.seoFlags.findMany({
where: { checkId }
}),
prisma.securityFlags.findMany({
where: { checkId }
})
]);
const analysis: any = {};
if (sslInspections.length > 0) {
analysis.ssl = sslInspections[0]; // Take the first (should be only one)
}
if (seoFlags.length > 0) {
analysis.seo = seoFlags[0]; // Take the first (should be only one)
}
if (securityFlags.length > 0) {
analysis.security = securityFlags[0]; // Take the first (should be only one)
}
return Object.keys(analysis).length > 0 ? analysis : null;
} catch (error) {
logger.error(`Failed to fetch analysis results for check ${checkId}:`, error);
return null;
}
}
}