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:
@@ -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);
|
||||
|
||||
446
apps/api/src/routes/projects.routes.ts
Normal file
446
apps/api/src/routes/projects.routes.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user