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;
}
}
}

View File

@@ -5,6 +5,16 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>URL Tracker Tool V2</title>
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-ZDZ26XYN2P"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-ZDZ26XYN2P');
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -26,6 +26,9 @@ import { BulkUploadPage } from './pages/BulkUploadPage';
// Context providers
import { AuthProvider } from './contexts/AuthContext';
// Analytics
import { RouteTracker } from './components/Analytics/RouteTracker';
// Create React Query client
const queryClient = new QueryClient({
defaultOptions: {
@@ -43,6 +46,7 @@ function App() {
<QueryClientProvider client={queryClient}>
<AuthProvider>
<Router>
<RouteTracker />
<Layout>
<Routes>
{/* Public routes */}

View File

@@ -0,0 +1,20 @@
/**
* Route Tracker Component
*
* Tracks page views for Google Analytics in SPA navigation
*/
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { trackPageView } from '../../utils/analytics';
export function RouteTracker() {
const location = useLocation();
useEffect(() => {
// Track page view on route change
trackPageView(location.pathname + location.search, document.title);
}, [location]);
return null; // This component doesn't render anything
}

View File

@@ -7,6 +7,7 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useToast } from '@chakra-ui/react';
import { authApi, AuthUser, LoginRequest, RegisterRequest } from '../services/api';
import { trackUserAuth } from '../utils/analytics';
interface AuthContextType {
user: AuthUser | null;
@@ -109,6 +110,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
authApi.logout();
setUser(null);
// Track logout
trackUserAuth('logout');
toast({
title: 'Logged out',
description: 'You have been successfully logged out.',

View File

@@ -42,6 +42,18 @@ import {
TabPanels,
Tab,
TabPanel,
Input,
Textarea,
FormControl,
FormLabel,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
useDisclosure,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
@@ -58,13 +70,17 @@ import {
} from 'react-icons/fi';
import { useAuth } from '../contexts/AuthContext';
import { trackingApi } from '../services/api';
// import type { CheckResult } from '../services/api'; // For future use
import { trackingApi, projectsApi, CreateProjectRequest } from '../services/api';
export function DashboardPage() {
const { isAuthenticated, user } = useAuth();
const toast = useToast();
const [selectedPeriod, setSelectedPeriod] = useState('7d');
const [newProjectName, setNewProjectName] = useState('');
const [newProjectDescription, setNewProjectDescription] = useState('');
const [isCreatingProject, setIsCreatingProject] = useState(false);
const { isOpen: isCreateModalOpen, onOpen: onCreateModalOpen, onClose: onCreateModalClose } = useDisclosure();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
@@ -85,7 +101,7 @@ export function DashboardPage() {
);
}
// Fetch recent checks
// Fetch recent checks (only when authenticated)
const {
data: recentChecks = [],
isLoading: isLoadingChecks,
@@ -95,6 +111,20 @@ export function DashboardPage() {
queryKey: ['recentChecks'],
queryFn: () => trackingApi.getRecentChecks(20),
refetchInterval: 30000, // Auto-refresh every 30 seconds
enabled: isAuthenticated, // Only run when authenticated
});
// Fetch projects (only when authenticated)
const {
data: projects = [],
isLoading: isLoadingProjects,
error: projectsError,
refetch: refetchProjects
} = useQuery({
queryKey: ['projects'],
queryFn: () => projectsApi.getProjects(),
refetchInterval: 60000, // Auto-refresh every minute
enabled: isAuthenticated, // Only run when authenticated
});
// Calculate dashboard metrics
@@ -130,6 +160,7 @@ export function DashboardPage() {
const handleRefresh = () => {
refetchChecks();
refetchProjects();
toast({
title: 'Dashboard refreshed',
status: 'success',
@@ -138,6 +169,63 @@ export function DashboardPage() {
});
};
const handleCreateProject = async () => {
if (!newProjectName.trim()) {
toast({
title: 'Project name required',
status: 'error',
duration: 3000,
isClosable: true,
});
return;
}
try {
setIsCreatingProject(true);
const projectData: CreateProjectRequest = {
name: newProjectName.trim(),
description: newProjectDescription.trim() || undefined,
settings: {
defaultMethod: 'GET',
defaultTimeout: 15000,
defaultMaxHops: 10,
enableSSLAnalysis: true,
enableSEOAnalysis: true,
enableSecurityAnalysis: true,
},
};
await projectsApi.createProject(projectData);
// Reset form and close modal
setNewProjectName('');
setNewProjectDescription('');
onCreateModalClose();
// Refresh projects
refetchProjects();
toast({
title: 'Project created successfully',
description: `${projectData.name} is ready for tracking`,
status: 'success',
duration: 3000,
isClosable: true,
});
} catch (error: any) {
toast({
title: 'Failed to create project',
description: error.response?.data?.message || 'Please try again',
status: 'error',
duration: 5000,
isClosable: true,
});
} finally {
setIsCreatingProject(false);
}
};
if (checksError) {
return (
<Container maxW="6xl">
@@ -455,21 +543,98 @@ export function DashboardPage() {
<CardHeader>
<HStack justify="space-between">
<Heading as="h3" size="md">
Project Management
Your Projects
</Heading>
<Button size="sm" colorScheme="brand" leftIcon={<Icon as={FiPlus} />}>
<Button
size="sm"
colorScheme="brand"
leftIcon={<Icon as={FiPlus} />}
onClick={onCreateModalOpen}
>
Create Project
</Button>
</HStack>
</CardHeader>
<CardBody>
<VStack py={8} spacing={4}>
<Icon as={FiBarChart} size="3rem" color="gray.400" />
<Text color="gray.600">Project management coming soon</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
Organize your checks into projects for better tracking and team collaboration.
</Text>
</VStack>
{isLoadingProjects ? (
<VStack py={8}>
<Spinner size="lg" colorScheme="brand" />
<Text color="gray.600">Loading projects...</Text>
</VStack>
) : projectsError ? (
<VStack py={8} spacing={4}>
<Alert status="error">
<AlertIcon />
Failed to load projects
</Alert>
<Button size="sm" onClick={() => refetchProjects()}>
Retry
</Button>
</VStack>
) : projects.length === 0 ? (
<VStack py={8} spacing={4}>
<Icon as={FiBarChart} size="3rem" color="gray.400" />
<Text color="gray.600">No projects yet</Text>
<Text fontSize="sm" color="gray.500" textAlign="center">
Create your first project to organize your URL tracking
</Text>
<Button
colorScheme="brand"
size="sm"
onClick={onCreateModalOpen}
leftIcon={<Icon as={FiPlus} />}
>
Create First Project
</Button>
</VStack>
) : (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{projects.map((project) => (
<Card key={project.id} bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody>
<VStack align="start" spacing={3}>
<HStack justify="space-between" w="full">
<Heading as="h4" size="sm" noOfLines={1}>
{project.name}
</Heading>
<Badge colorScheme="blue" variant="subtle">
{project.trackingCount} checks
</Badge>
</HStack>
{project.description && (
<Text fontSize="sm" color="gray.600" noOfLines={2}>
{project.description}
</Text>
)}
<HStack justify="space-between" w="full">
<Text fontSize="xs" color="gray.500">
Created {formatTimeAgo(project.createdAt)}
</Text>
<Button
size="xs"
variant="ghost"
leftIcon={<Icon as={FiEye} />}
onClick={() => {
// Navigate to project details (future feature)
toast({
title: 'Project details',
description: 'Project detail view coming soon',
status: 'info',
duration: 2000,
});
}}
>
View
</Button>
</HStack>
</VStack>
</CardBody>
</Card>
))}
</SimpleGrid>
)}
</CardBody>
</Card>
</TabPanel>
@@ -545,6 +710,51 @@ export function DashboardPage() {
</TabPanel>
</TabPanels>
</Tabs>
{/* Create Project Modal */}
<Modal isOpen={isCreateModalOpen} onClose={onCreateModalClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Create New Project</ModalHeader>
<ModalCloseButton />
<ModalBody>
<VStack spacing={4}>
<FormControl isRequired>
<FormLabel>Project Name</FormLabel>
<Input
placeholder="Enter project name"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
/>
</FormControl>
<FormControl>
<FormLabel>Description</FormLabel>
<Textarea
placeholder="Describe your project (optional)"
value={newProjectDescription}
onChange={(e) => setNewProjectDescription(e.target.value)}
rows={3}
/>
</FormControl>
</VStack>
</ModalBody>
<ModalFooter>
<Button variant="ghost" mr={3} onClick={onCreateModalClose}>
Cancel
</Button>
<Button
colorScheme="brand"
onClick={handleCreateProject}
isLoading={isCreatingProject}
loadingText="Creating..."
>
Create Project
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</VStack>
</Container>
);

View File

@@ -3,6 +3,7 @@
*/
import { useState } from 'react';
import { trackUrlSubmission, trackAnalysisResult, trackError } from '../utils/analytics';
import {
Box,
Heading,
@@ -49,7 +50,7 @@ import {
// Image,
// Center,
} from '@chakra-ui/react';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { Link as RouterLink } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -90,7 +91,6 @@ export function HomePage() {
const [showAdvanced, setShowAdvanced] = useState(false);
const [trackingResult, setTrackingResult] = useState<any>(null);
const { isAuthenticated } = useAuth();
const navigate = useNavigate();
const toast = useToast();
const cardBg = useColorModeValue('white', 'gray.800');
@@ -124,6 +124,8 @@ export function HomePage() {
// Tracking mutation
const trackingMutation = useMutation({
mutationFn: async (data: TrackRequestV2) => {
// Track URL submission
trackUrlSubmission(data.url, isAuthenticated);
return await trackingApi.trackUrlV2(data);
},
onSuccess: (result) => {
@@ -136,12 +138,24 @@ export function HomePage() {
isClosable: true,
});
// Navigate to check detail page for authenticated users
if (isAuthenticated) {
navigate(`/check/${result.check.id}`);
// Track analysis results
const analysis = (result as any).check?.analysis;
if (analysis) {
trackAnalysisResult(
(result as any).check.redirectCount || 0,
analysis.ssl?.warningsJson?.length > 0 || false,
!analysis.seo?.robotsTxtStatus || analysis.seo?.noindex || false,
analysis.security?.mixedContent === 'PRESENT' || analysis.security?.safeBrowsingStatus !== 'safe' || false
);
}
// Don't auto-navigate on home page to allow users to see results
// They can manually navigate to their dashboard to see saved results
},
onError: (error: any) => {
// Track errors
trackError('tracking_failed', error.response?.data?.message || error.message || 'Unknown error');
toast({
title: 'Tracking failed',
description: error.response?.data?.message || 'An error occurred',

View File

@@ -1,24 +1,86 @@
/**
* Login Page - Placeholder for Phase 4
* Login Page - User Authentication
*/
import { useState } from 'react';
import { trackUserAuth } from '../utils/analytics';
import {
Box,
Heading,
Text,
Container,
VStack,
Badge,
HStack,
Card,
CardBody,
Button,
FormControl,
FormLabel,
Input,
FormErrorMessage,
InputGroup,
InputRightElement,
IconButton,
Alert,
AlertIcon,
Divider,
useColorModeValue,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink, useNavigate, useLocation } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FiEye, FiEyeOff } from 'react-icons/fi';
import { useAuth } from '../contexts/AuthContext';
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function LoginPage() {
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const {
register,
handleSubmit,
formState: { errors },
setError,
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
setIsLoading(true);
await login(data);
// Track successful login
trackUserAuth('login');
// Redirect to intended page or dashboard
const from = location.state?.from?.pathname || '/dashboard';
navigate(from, { replace: true });
} catch (error: any) {
setError('root', {
message: error.response?.data?.message || 'Login failed. Please try again.',
});
} finally {
setIsLoading(false);
}
};
return (
<Container maxW="md">
<Container maxW="md" py={12}>
<VStack spacing={8}>
<Box textAlign="center">
<Heading as="h1" size="xl" mb={4}>
@@ -27,40 +89,94 @@ export function LoginPage() {
<Text color="gray.600">
Access your redirect tracking dashboard
</Text>
<Badge colorScheme="yellow" mt={2}>
Login UI coming in Phase 4
</Badge>
</Box>
<Card w="full">
<CardBody textAlign="center" py={8}>
<Text mb={6} color="gray.600">
The authentication system is fully implemented in the backend API.
The login form UI will be completed in the next phase.
</Text>
<VStack spacing={4}>
<Text fontSize="sm" fontWeight="medium">
Backend Features Ready:
</Text>
<VStack fontSize="sm" color="gray.600">
<Text> User registration and login</Text>
<Text> JWT token authentication</Text>
<Text> Argon2 password hashing</Text>
<Text> Organization management</Text>
<Card w="full" bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody p={8}>
{errors.root && (
<Alert status="error" mb={6} borderRadius="md">
<AlertIcon />
{errors.root.message}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={6}>
<FormControl isInvalid={!!errors.email}>
<FormLabel>Email</FormLabel>
<Input
type="email"
placeholder="Enter your email"
autoComplete="email"
{...register('email')}
/>
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.password}>
<FormLabel>Password</FormLabel>
<InputGroup>
<Input
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
autoComplete="current-password"
{...register('password')}
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? 'Hide password' : 'Show password'}
icon={showPassword ? <FiEyeOff /> : <FiEye />}
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.password?.message}</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme="brand"
size="lg"
w="full"
isLoading={isLoading}
loadingText="Signing in..."
>
Sign In
</Button>
</VStack>
</form>
<Divider my={6} />
<VStack spacing={4}>
<HStack>
<Text fontSize="sm" color="gray.600">
Don't have an account?
</Text>
<Button
as={RouterLink}
to="/register"
variant="link"
colorScheme="brand"
size="sm"
>
Sign up
</Button>
</HStack>
<Button
as={RouterLink}
to="/track"
variant="outline"
size="sm"
w="full"
>
Continue as Guest
</Button>
</VStack>
</CardBody>
</Card>
<VStack>
<Button as={RouterLink} to="/track" colorScheme="brand">
Try Anonymous Tracking
</Button>
<Text fontSize="sm" color="gray.600">
or test the API directly at <Text as="span" fontFamily="mono">/api/v1/auth/login</Text>
</Text>
</VStack>
</VStack>
</Container>
);

View File

@@ -1,24 +1,119 @@
/**
* Register Page - Placeholder for Phase 4
* Register Page - User Registration
*/
import { useState } from 'react';
import { trackUserAuth } from '../utils/analytics';
import {
Box,
Heading,
Text,
Container,
VStack,
Badge,
HStack,
Card,
CardBody,
Button,
FormControl,
FormLabel,
Input,
FormErrorMessage,
InputGroup,
InputRightElement,
IconButton,
Alert,
AlertIcon,
Divider,
useColorModeValue,
SimpleGrid,
List,
ListItem,
ListIcon,
} from '@chakra-ui/react';
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FiEye, FiEyeOff, FiCheck } from 'react-icons/fi';
import { useAuth } from '../contexts/AuthContext';
const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number')
.regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type RegisterFormData = z.infer<typeof registerSchema>;
export function RegisterPage() {
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { register: registerUser } = useAuth();
const navigate = useNavigate();
const cardBg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.600');
const {
register,
handleSubmit,
formState: { errors },
setError,
watch,
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const password = watch('password');
const onSubmit = async (data: RegisterFormData) => {
try {
setIsLoading(true);
await registerUser({
name: data.name,
email: data.email,
password: data.password,
});
// Track successful registration
trackUserAuth('register');
// Navigate to dashboard after successful registration
navigate('/dashboard', { replace: true });
} catch (error: any) {
setError('root', {
message: error.response?.data?.message || 'Registration failed. Please try again.',
});
} finally {
setIsLoading(false);
}
};
const getPasswordValidationColor = (valid: boolean) => {
if (!password) return 'gray.500';
return valid ? 'green.500' : 'red.500';
};
const passwordValidations = [
{ label: 'At least 8 characters', valid: password?.length >= 8 },
{ label: 'One uppercase letter', valid: /[A-Z]/.test(password || '') },
{ label: 'One lowercase letter', valid: /[a-z]/.test(password || '') },
{ label: 'One number', valid: /[0-9]/.test(password || '') },
{ label: 'One special character', valid: /[^A-Za-z0-9]/.test(password || '') },
];
return (
<Container maxW="md">
<Container maxW="lg" py={12}>
<VStack spacing={8}>
<Box textAlign="center">
<Heading as="h1" size="xl" mb={4}>
@@ -27,40 +122,190 @@ export function RegisterPage() {
<Text color="gray.600">
Get started with enhanced redirect tracking
</Text>
<Badge colorScheme="yellow" mt={2}>
Registration UI coming in Phase 4
</Badge>
</Box>
<Card w="full">
<CardBody textAlign="center" py={8}>
<Text mb={6} color="gray.600">
The user registration system is fully implemented in the backend API.
The registration form UI will be completed in the next phase.
</Text>
<VStack spacing={4}>
<Text fontSize="sm" fontWeight="medium">
Account Benefits:
</Text>
<VStack fontSize="sm" color="gray.600">
<Text>🚀 Higher rate limits (200/hour)</Text>
<Text>💾 Saved tracking history</Text>
<Text>📊 Analysis dashboards</Text>
<Text>🏢 Organization management</Text>
</VStack>
</VStack>
</CardBody>
</Card>
<SimpleGrid columns={{ base: 1, lg: 2 }} spacing={8} w="full">
{/* Registration Form */}
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody p={8}>
{errors.root && (
<Alert status="error" mb={6} borderRadius="md">
<AlertIcon />
{errors.root.message}
</Alert>
)}
<VStack>
<Button as={RouterLink} to="/track" colorScheme="brand">
Try Anonymous Tracking
</Button>
<Text fontSize="sm" color="gray.600">
or test the API directly at <Text as="span" fontFamily="mono">/api/v1/auth/register</Text>
</Text>
</VStack>
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={6}>
<FormControl isInvalid={!!errors.name}>
<FormLabel>Full Name</FormLabel>
<Input
placeholder="Enter your full name"
autoComplete="name"
{...register('name')}
/>
<FormErrorMessage>{errors.name?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.email}>
<FormLabel>Email</FormLabel>
<Input
type="email"
placeholder="Enter your email"
autoComplete="email"
{...register('email')}
/>
<FormErrorMessage>{errors.email?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.password}>
<FormLabel>Password</FormLabel>
<InputGroup>
<Input
type={showPassword ? 'text' : 'password'}
placeholder="Create a strong password"
autoComplete="new-password"
{...register('password')}
/>
<InputRightElement>
<IconButton
aria-label={showPassword ? 'Hide password' : 'Show password'}
icon={showPassword ? <FiEyeOff /> : <FiEye />}
variant="ghost"
onClick={() => setShowPassword(!showPassword)}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.password?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.confirmPassword}>
<FormLabel>Confirm Password</FormLabel>
<InputGroup>
<Input
type={showConfirmPassword ? 'text' : 'password'}
placeholder="Confirm your password"
autoComplete="new-password"
{...register('confirmPassword')}
/>
<InputRightElement>
<IconButton
aria-label={showConfirmPassword ? 'Hide password' : 'Show password'}
icon={showConfirmPassword ? <FiEyeOff /> : <FiEye />}
variant="ghost"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
/>
</InputRightElement>
</InputGroup>
<FormErrorMessage>{errors.confirmPassword?.message}</FormErrorMessage>
</FormControl>
<Button
type="submit"
colorScheme="brand"
size="lg"
w="full"
isLoading={isLoading}
loadingText="Creating account..."
>
Create Account
</Button>
</VStack>
</form>
<Divider my={6} />
<VStack spacing={4}>
<HStack>
<Text fontSize="sm" color="gray.600">
Already have an account?
</Text>
<Button
as={RouterLink}
to="/login"
variant="link"
colorScheme="brand"
size="sm"
>
Sign in
</Button>
</HStack>
<Button
as={RouterLink}
to="/track"
variant="outline"
size="sm"
w="full"
>
Continue as Guest
</Button>
</VStack>
</CardBody>
</Card>
{/* Benefits & Password Requirements */}
<VStack spacing={6} align="stretch">
{/* Account Benefits */}
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody>
<Heading size="md" mb={4}>Account Benefits</Heading>
<List spacing={2}>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Higher rate limits (1000/hour)
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Saved tracking history
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Analysis dashboards
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Organization management
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
API key access
</ListItem>
<ListItem>
<ListIcon as={FiCheck} color="green.500" />
Bulk URL processing
</ListItem>
</List>
</CardBody>
</Card>
{/* Password Requirements */}
{password && (
<Card bg={cardBg} border="1px solid" borderColor={borderColor}>
<CardBody>
<Heading size="md" mb={4}>Password Requirements</Heading>
<List spacing={2}>
{passwordValidations.map((validation, index) => (
<ListItem key={index}>
<ListIcon
as={FiCheck}
color={getPasswordValidationColor(validation.valid)}
/>
<Text
as="span"
color={getPasswordValidationColor(validation.valid)}
fontSize="sm"
>
{validation.label}
</Text>
</ListItem>
))}
</List>
</CardBody>
</Card>
)}
</VStack>
</SimpleGrid>
</VStack>
</Container>
);

View File

@@ -7,7 +7,7 @@
import axios, { AxiosInstance, AxiosResponse } from 'axios';
// Base configuration
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3333';
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || 'http://localhost:3334';
// Create axios instance
const api: AxiosInstance = axios.create({
@@ -199,6 +199,61 @@ export interface SecurityAnalysisResult {
securityScore: number;
}
// Project Management Types
export interface Project {
id: string;
name: string;
description: string | null;
settings: {
description?: string;
defaultMethod?: 'GET' | 'POST' | 'HEAD';
defaultTimeout?: number;
defaultMaxHops?: number;
enableSSLAnalysis?: boolean;
enableSEOAnalysis?: boolean;
enableSecurityAnalysis?: boolean;
};
trackingCount: number;
createdAt: string;
}
export interface ProjectDetails extends Project {
recentChecks: Array<{
id: string;
inputUrl: string;
finalUrl: string | null;
status: string;
startedAt: string;
hopCount: number;
}>;
}
export interface CreateProjectRequest {
name: string;
description?: string;
settings?: {
defaultMethod?: 'GET' | 'POST' | 'HEAD';
defaultTimeout?: number;
defaultMaxHops?: number;
enableSSLAnalysis?: boolean;
enableSEOAnalysis?: boolean;
enableSecurityAnalysis?: boolean;
};
}
export interface UpdateProjectRequest {
name?: string;
description?: string;
settings?: {
defaultMethod?: 'GET' | 'POST' | 'HEAD';
defaultTimeout?: number;
defaultMaxHops?: number;
enableSSLAnalysis?: boolean;
enableSEOAnalysis?: boolean;
enableSecurityAnalysis?: boolean;
};
}
// ============================================================================
// API SERVICES
// ============================================================================
@@ -300,6 +355,32 @@ export const analysisApi = {
},
};
export const projectsApi = {
async getProjects(): Promise<Project[]> {
const response: AxiosResponse<{ data: { projects: Project[] } }> = await api.get('/v2/projects');
return response.data.data.projects;
},
async getProject(projectId: string): Promise<ProjectDetails> {
const response: AxiosResponse<{ data: { project: ProjectDetails } }> = await api.get(`/v2/projects/${projectId}`);
return response.data.data.project;
},
async createProject(projectData: CreateProjectRequest): Promise<Project> {
const response: AxiosResponse<{ data: { project: Project } }> = await api.post('/v2/projects', projectData);
return response.data.data.project;
},
async updateProject(projectId: string, updates: UpdateProjectRequest): Promise<Project> {
const response: AxiosResponse<{ data: { project: Project } }> = await api.put(`/v2/projects/${projectId}`, updates);
return response.data.data.project;
},
async deleteProject(projectId: string): Promise<void> {
await api.delete(`/v2/projects/${projectId}`);
},
};
export const healthApi = {
async getHealth(): Promise<{
status: string;

View File

@@ -0,0 +1,110 @@
/**
* Google Analytics Utility Functions
*
* Provides type-safe Google Analytics event tracking
*/
// Extend the Window interface to include gtag
declare global {
interface Window {
gtag: (
command: 'config' | 'event' | 'js' | 'set',
targetId: string | Date,
config?: Record<string, any>
) => void;
dataLayer: any[];
}
}
/**
* Track a custom event in Google Analytics
*/
export const trackEvent = (
eventName: string,
parameters?: {
event_category?: string;
event_label?: string;
value?: number;
custom_parameters?: Record<string, any>;
}
) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('event', eventName, {
event_category: parameters?.event_category,
event_label: parameters?.event_label,
value: parameters?.value,
...parameters?.custom_parameters,
});
}
};
/**
* Track URL tracking events
*/
export const trackUrlSubmission = (url: string, isAuthenticated: boolean) => {
trackEvent('url_track', {
event_category: 'tracking',
event_label: isAuthenticated ? 'authenticated' : 'anonymous',
custom_parameters: {
url_domain: new URL(url).hostname,
user_type: isAuthenticated ? 'authenticated' : 'anonymous',
},
});
};
/**
* Track user authentication events
*/
export const trackUserAuth = (action: 'login' | 'register' | 'logout') => {
trackEvent('user_auth', {
event_category: 'authentication',
event_label: action,
});
};
/**
* Track page views (for SPA navigation)
*/
export const trackPageView = (pagePath: string, pageTitle?: string) => {
if (typeof window !== 'undefined' && window.gtag) {
window.gtag('config', 'G-ZDZ26XYN2P', {
page_path: pagePath,
page_title: pageTitle,
});
}
};
/**
* Track analysis results
*/
export const trackAnalysisResult = (
redirectCount: number,
hasSSLIssues: boolean,
hasSEOIssues: boolean,
hasSecurityIssues: boolean
) => {
trackEvent('analysis_complete', {
event_category: 'analysis',
event_label: 'redirect_analysis',
value: redirectCount,
custom_parameters: {
redirect_count: redirectCount,
ssl_issues: hasSSLIssues,
seo_issues: hasSEOIssues,
security_issues: hasSecurityIssues,
},
});
};
/**
* Track errors
*/
export const trackError = (errorType: string, errorMessage: string) => {
trackEvent('error', {
event_category: 'error',
event_label: errorType,
custom_parameters: {
error_message: errorMessage,
},
});
};

View File

@@ -0,0 +1,264 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER');
-- CreateEnum
CREATE TYPE "CheckStatus" AS ENUM ('OK', 'ERROR', 'TIMEOUT', 'LOOP');
-- CreateEnum
CREATE TYPE "RedirectType" AS ENUM ('HTTP_301', 'HTTP_302', 'HTTP_307', 'HTTP_308', 'META_REFRESH', 'JS', 'FINAL', 'OTHER');
-- CreateEnum
CREATE TYPE "MixedContent" AS ENUM ('NONE', 'PRESENT', 'FINAL_TO_HTTP');
-- CreateEnum
CREATE TYPE "JobStatus" AS ENUM ('PENDING', 'QUEUED', 'RUNNING', 'COMPLETED', 'FAILED', 'CANCELLED', 'ERROR');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_login_at" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"plan" TEXT NOT NULL DEFAULT 'free',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "org_memberships" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"role" "Role" NOT NULL,
CONSTRAINT "org_memberships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "projects" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"settings_json" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "checks" (
"id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"input_url" TEXT NOT NULL,
"method" TEXT NOT NULL DEFAULT 'GET',
"headers_json" JSONB NOT NULL DEFAULT '{}',
"user_agent" TEXT,
"started_at" TIMESTAMP(3) NOT NULL,
"finished_at" TIMESTAMP(3),
"status" "CheckStatus" NOT NULL,
"final_url" TEXT,
"total_time_ms" INTEGER,
"report_id" TEXT,
CONSTRAINT "checks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "hops" (
"id" TEXT NOT NULL,
"check_id" TEXT NOT NULL,
"hop_index" INTEGER NOT NULL,
"url" TEXT NOT NULL,
"scheme" TEXT,
"status_code" INTEGER,
"redirect_type" "RedirectType" NOT NULL,
"latency_ms" INTEGER,
"content_type" TEXT,
"reason" TEXT,
"response_headers_json" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "hops_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ssl_inspections" (
"id" TEXT NOT NULL,
"check_id" TEXT NOT NULL,
"host" TEXT NOT NULL,
"valid_from" TIMESTAMP(3),
"valid_to" TIMESTAMP(3),
"days_to_expiry" INTEGER,
"issuer" TEXT,
"protocol" TEXT,
"warnings_json" JSONB NOT NULL DEFAULT '[]',
CONSTRAINT "ssl_inspections_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "seo_flags" (
"id" TEXT NOT NULL,
"check_id" TEXT NOT NULL,
"robots_txt_status" TEXT,
"robots_txt_rules_json" JSONB NOT NULL DEFAULT '{}',
"meta_robots" TEXT,
"canonical_url" TEXT,
"sitemap_present" BOOLEAN NOT NULL DEFAULT false,
"noindex" BOOLEAN NOT NULL DEFAULT false,
"nofollow" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "seo_flags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "security_flags" (
"id" TEXT NOT NULL,
"check_id" TEXT NOT NULL,
"safe_browsing_status" TEXT,
"mixed_content" "MixedContent" NOT NULL DEFAULT 'NONE',
"https_to_http" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "security_flags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "reports" (
"id" TEXT NOT NULL,
"check_id" TEXT NOT NULL,
"markdown_path" TEXT,
"pdf_path" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "reports_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "bulk_jobs" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"organization_id" TEXT,
"project_id" TEXT NOT NULL,
"upload_path" TEXT NOT NULL,
"status" "JobStatus" NOT NULL,
"total_urls" INTEGER NOT NULL DEFAULT 0,
"processed_urls" INTEGER NOT NULL DEFAULT 0,
"successful_urls" INTEGER NOT NULL DEFAULT 0,
"failed_urls" INTEGER NOT NULL DEFAULT 0,
"config_json" JSONB NOT NULL DEFAULT '{}',
"urls_json" JSONB,
"results_json" JSONB,
"progress_json" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"started_at" TIMESTAMP(3),
"finished_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
CONSTRAINT "bulk_jobs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "api_keys" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"perms_json" JSONB NOT NULL DEFAULT '{}',
"rate_limit_quota" INTEGER NOT NULL DEFAULT 1000,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "api_keys_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"org_id" TEXT NOT NULL,
"actor_user_id" TEXT,
"action" TEXT NOT NULL,
"entity" TEXT NOT NULL,
"entity_id" TEXT NOT NULL,
"meta_json" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "org_memberships_org_id_user_id_key" ON "org_memberships"("org_id", "user_id");
-- CreateIndex
CREATE INDEX "checks_project_id_started_at_idx" ON "checks"("project_id", "started_at" DESC);
-- CreateIndex
CREATE INDEX "hops_check_id_hop_index_idx" ON "hops"("check_id", "hop_index");
-- CreateIndex
CREATE UNIQUE INDEX "seo_flags_check_id_key" ON "seo_flags"("check_id");
-- CreateIndex
CREATE UNIQUE INDEX "security_flags_check_id_key" ON "security_flags"("check_id");
-- CreateIndex
CREATE UNIQUE INDEX "api_keys_token_hash_key" ON "api_keys"("token_hash");
-- CreateIndex
CREATE INDEX "api_keys_token_hash_idx" ON "api_keys"("token_hash");
-- AddForeignKey
ALTER TABLE "org_memberships" ADD CONSTRAINT "org_memberships_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "org_memberships" ADD CONSTRAINT "org_memberships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "checks" ADD CONSTRAINT "checks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "hops" ADD CONSTRAINT "hops_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ssl_inspections" ADD CONSTRAINT "ssl_inspections_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "seo_flags" ADD CONSTRAINT "seo_flags_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "security_flags" ADD CONSTRAINT "security_flags_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "reports" ADD CONSTRAINT "reports_check_id_fkey" FOREIGN KEY ("check_id") REFERENCES "checks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bulk_jobs" ADD CONSTRAINT "bulk_jobs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bulk_jobs" ADD CONSTRAINT "bulk_jobs_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "bulk_jobs" ADD CONSTRAINT "bulk_jobs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_user_id_fkey" FOREIGN KEY ("actor_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"

69
service-control.sh Executable file
View File

@@ -0,0 +1,69 @@
#!/bin/bash
# URL Tracker Tool V2 - Service Control Script
# Usage: ./service-control.sh [start|stop|restart|status|logs|enable|disable]
SERVICE_NAME="catch-redirect"
case "$1" in
start)
echo "🚀 Starting URL Tracker Tool V2 service..."
systemctl start $SERVICE_NAME
sleep 3
systemctl status $SERVICE_NAME --no-pager
;;
stop)
echo "🛑 Stopping URL Tracker Tool V2 service..."
systemctl stop $SERVICE_NAME
echo "Service stopped."
;;
restart)
echo "🔄 Restarting URL Tracker Tool V2 service..."
systemctl restart $SERVICE_NAME
sleep 3
systemctl status $SERVICE_NAME --no-pager
;;
status)
echo "📊 URL Tracker Tool V2 service status:"
systemctl status $SERVICE_NAME --no-pager
echo ""
echo "🌐 Service Health Check:"
echo "API (port 3334): $(curl -s http://localhost:3334/health | jq -r '.status' 2>/dev/null || echo "Not responding")"
echo "Frontend (port 3000): $(curl -s http://localhost:3000 >/dev/null && echo "Running" || echo "Not responding")"
;;
logs)
echo "📝 Recent service logs:"
echo "=== Main Service Log ==="
tail -20 /var/log/catch-redirect.log
echo ""
echo "=== API Log ==="
tail -10 /var/log/catch-redirect-api.log 2>/dev/null || echo "No API logs yet"
echo ""
echo "=== Frontend Log ==="
tail -10 /var/log/catch-redirect-frontend.log 2>/dev/null || echo "No frontend logs yet"
;;
enable)
echo "⚡ Enabling URL Tracker Tool V2 service for auto-start..."
systemctl enable $SERVICE_NAME
echo "Service will now start automatically on boot."
;;
disable)
echo "❌ Disabling URL Tracker Tool V2 service auto-start..."
systemctl disable $SERVICE_NAME
echo "Service will no longer start automatically on boot."
;;
*)
echo "URL Tracker Tool V2 - Service Control"
echo "Usage: $0 [start|stop|restart|status|logs|enable|disable]"
echo ""
echo "Commands:"
echo " start - Start the service"
echo " stop - Stop the service"
echo " restart - Restart the service"
echo " status - Show service status and health check"
echo " logs - Show recent service logs"
echo " enable - Enable auto-start on boot"
echo " disable - Disable auto-start on boot"
exit 1
;;
esac

68
start-services.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# URL Tracker Tool V2 - Service Startup Script
# This script starts both API and Frontend services
set -e
# Environment variables
export NODE_ENV="production"
export PORT=3334
export CORS_ORIGIN="https://urltrackertool.com"
export DATABASE_URL="postgresql://postgres:postgres@localhost:5432/redirect_intelligence_v2?schema=public"
export JWT_SECRET="J9k2L7mP4nQ8vX3w6Z9c2F5h8K1m4P7r0T3w6Y9b2E5g8J1l4O7q0S3v6Z9c2F5h"
# Change to app directory
cd /root/catch_redirect
# Log startup
echo "$(date): Starting URL Tracker Tool V2 services..." >> /var/log/catch-redirect.log
# Kill any existing processes
pkill -f "node dist/index.js" || true
pkill -f "npx serve" || true
sleep 2
# Start API service
echo "$(date): Starting API service on port $PORT..." >> /var/log/catch-redirect.log
cd /root/catch_redirect/apps/api
nohup node dist/index.js >> /var/log/catch-redirect-api.log 2>&1 &
API_PID=$!
echo "$(date): API service started with PID $API_PID" >> /var/log/catch-redirect.log
# Wait for API to be ready
sleep 5
# Start Frontend service
echo "$(date): Starting Frontend service on port 3000..." >> /var/log/catch-redirect.log
cd /root/catch_redirect/apps/web
nohup serve dist -l 3000 >> /var/log/catch-redirect-frontend.log 2>&1 &
FRONTEND_PID=$!
echo "$(date): Frontend service started with PID $FRONTEND_PID" >> /var/log/catch-redirect.log
# Save PIDs for monitoring
echo "$API_PID" > /var/run/catch-redirect-api.pid
echo "$FRONTEND_PID" > /var/run/catch-redirect-frontend.pid
# Wait and monitor
sleep 3
# Check if services are running
if curl -s http://localhost:3334/health > /dev/null; then
echo "$(date): API service health check passed" >> /var/log/catch-redirect.log
else
echo "$(date): ERROR - API service health check failed" >> /var/log/catch-redirect.log
exit 1
fi
if curl -s http://localhost:3000 > /dev/null; then
echo "$(date): Frontend service health check passed" >> /var/log/catch-redirect.log
else
echo "$(date): ERROR - Frontend service health check failed" >> /var/log/catch-redirect.log
exit 1
fi
echo "$(date): All services started successfully" >> /var/log/catch-redirect.log
# Keep the script running to maintain the service
wait

28
stop-services.sh Executable file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# URL Tracker Tool V2 - Service Stop Script
# This script stops both API and Frontend services cleanly
echo "$(date): Stopping URL Tracker Tool V2 services..." >> /var/log/catch-redirect.log
# Stop API service
if [ -f /var/run/catch-redirect-api.pid ]; then
API_PID=$(cat /var/run/catch-redirect-api.pid)
echo "$(date): Stopping API service (PID: $API_PID)..." >> /var/log/catch-redirect.log
kill $API_PID 2>/dev/null || true
rm -f /var/run/catch-redirect-api.pid
fi
# Stop Frontend service
if [ -f /var/run/catch-redirect-frontend.pid ]; then
FRONTEND_PID=$(cat /var/run/catch-redirect-frontend.pid)
echo "$(date): Stopping Frontend service (PID: $FRONTEND_PID)..." >> /var/log/catch-redirect.log
kill $FRONTEND_PID 2>/dev/null || true
rm -f /var/run/catch-redirect-frontend.pid
fi
# Force kill any remaining processes
pkill -f "node dist/index.js" || true
pkill -f "serve dist" || true
echo "$(date): All services stopped" >> /var/log/catch-redirect.log