diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7150b2d5..44a50a50 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -15,6 +15,7 @@ import rateLimit from 'express-rate-limit'; import path from 'path'; import { logger } from './lib/logger'; import { trackRedirects } from './services/redirect-legacy.service'; +import authRoutes from './routes/auth.routes'; const app = express(); const PORT = process.env.PORT || 3333; @@ -58,6 +59,13 @@ const apiLimiter = rateLimit({ legacyHeaders: false, // Disable the `X-RateLimit-*` headers }); +// ============================================================================ +// NEW V2 API ROUTES +// ============================================================================ + +// Authentication routes +app.use('/api/v1/auth', authRoutes); + // Health check endpoint app.get('/health', (req, res) => { res.json({ diff --git a/apps/api/src/middleware/auth.middleware.ts b/apps/api/src/middleware/auth.middleware.ts new file mode 100644 index 00000000..0aa6b1e2 --- /dev/null +++ b/apps/api/src/middleware/auth.middleware.ts @@ -0,0 +1,216 @@ +/** + * Authentication Middleware for Redirect Intelligence v2 + * + * Provides JWT token authentication and user context injection + */ + +import { Request, Response, NextFunction } from 'express'; +import { AuthService, AuthUser } from '../services/auth.service'; +import { logger } from '../lib/logger'; + +// Extend Request interface to include user +export interface AuthenticatedRequest extends Request { + user?: AuthUser; +} + +export class AuthMiddleware { + private authService = new AuthService(); + + /** + * Extract token from request headers or cookies + */ + private extractToken(req: Request): string | null { + // Try Authorization header first (Bearer token) + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + // Try cookie as fallback + const cookieToken = req.cookies?.auth_token; + if (cookieToken) { + return cookieToken; + } + + return null; + } + + /** + * Required authentication middleware + * Returns 401 if user is not authenticated + */ + requireAuth = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const token = this.extractToken(req); + + if (!token) { + logger.warn('Authentication required but no token provided'); + return res.status(401).json({ + success: false, + error: 'Authentication required', + message: 'Please provide a valid authentication token' + }); + } + + // Verify token + const decoded = this.authService.verifyToken(token); + + // Get user details + const user = await this.authService.getUserById(decoded.userId); + + if (!user) { + logger.warn(`Token valid but user not found: ${decoded.userId}`); + return res.status(401).json({ + success: false, + error: 'User not found', + message: 'The authenticated user no longer exists' + }); + } + + // Attach user to request + req.user = user; + + logger.debug(`User authenticated: ${user.email}`); + next(); + + } catch (error) { + logger.warn('Authentication failed:', error); + return res.status(401).json({ + success: false, + error: 'Invalid token', + message: 'Please provide a valid authentication token' + }); + } + }; + + /** + * Optional authentication middleware + * Attaches user if token is valid, but doesn't require it + */ + optionalAuth = async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + try { + const token = this.extractToken(req); + + if (!token) { + // No token provided, continue without authentication + return next(); + } + + // Verify token + const decoded = this.authService.verifyToken(token); + + // Get user details + const user = await this.authService.getUserById(decoded.userId); + + if (user) { + req.user = user; + logger.debug(`Optional auth successful: ${user.email}`); + } + + } catch (error) { + // Token invalid, but that's okay for optional auth + logger.debug('Optional auth failed (continuing):', error); + } + + next(); + }; + + /** + * Require specific role in organization + */ + requireRole = (orgIdParam: string, requiredRole: string) => { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ + success: false, + error: 'Authentication required' + }); + } + + // Get organization ID from request params + const orgId = req.params[orgIdParam] || req.body[orgIdParam]; + + if (!orgId) { + return res.status(400).json({ + success: false, + error: 'Organization ID required' + }); + } + + // Check if user has the required role + const userRole = await this.authService.getUserRole(req.user.id, orgId); + + if (!userRole) { + return res.status(403).json({ + success: false, + error: 'Access denied', + message: 'You are not a member of this organization' + }); + } + + // Define role hierarchy + const roleHierarchy = { + 'MEMBER': 1, + 'ADMIN': 2, + 'OWNER': 3, + }; + + const userRoleLevel = roleHierarchy[userRole as keyof typeof roleHierarchy] || 0; + const requiredRoleLevel = roleHierarchy[requiredRole as keyof typeof roleHierarchy] || 999; + + if (userRoleLevel < requiredRoleLevel) { + return res.status(403).json({ + success: false, + error: 'Insufficient permissions', + message: `This action requires ${requiredRole} role or higher` + }); + } + + next(); + }; + }; + + /** + * Check organization access + */ + requireOrgAccess = (orgIdParam: string) => { + return async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + if (!req.user) { + return res.status(401).json({ + success: false, + error: 'Authentication required' + }); + } + + const orgId = req.params[orgIdParam] || req.body[orgIdParam]; + + if (!orgId) { + return res.status(400).json({ + success: false, + error: 'Organization ID required' + }); + } + + const hasAccess = await this.authService.hasOrgAccess(req.user.id, orgId); + + if (!hasAccess) { + return res.status(403).json({ + success: false, + error: 'Access denied', + message: 'You do not have access to this organization' + }); + } + + next(); + }; + }; +} + +// Export singleton instance +export const authMiddleware = new AuthMiddleware(); + +// Export individual middleware functions for convenience +export const requireAuth = authMiddleware.requireAuth; +export const optionalAuth = authMiddleware.optionalAuth; +export const requireRole = authMiddleware.requireRole; +export const requireOrgAccess = authMiddleware.requireOrgAccess; diff --git a/apps/api/src/routes/auth.routes.ts b/apps/api/src/routes/auth.routes.ts new file mode 100644 index 00000000..abfcc313 --- /dev/null +++ b/apps/api/src/routes/auth.routes.ts @@ -0,0 +1,266 @@ +/** + * Authentication Routes for Redirect Intelligence v2 + * + * Handles user login, registration, logout, and profile management + */ + +import express from 'express'; +import { z } from 'zod'; +import rateLimit from 'express-rate-limit'; +import { AuthService } from '../services/auth.service'; +import { requireAuth, AuthenticatedRequest } from '../middleware/auth.middleware'; +import { logger } from '../lib/logger'; + +const router = express.Router(); +const authService = new AuthService(); + +// Rate limiting for auth endpoints +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 5, // limit each IP to 5 requests per windowMs + message: { + success: false, + error: 'Too many authentication attempts', + message: 'Please try again later' + }, + standardHeaders: true, + legacyHeaders: false, +}); + +const registerLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 3, // limit each IP to 3 registration attempts per hour + message: { + success: false, + error: 'Too many registration attempts', + message: 'Please try again later' + }, +}); + +// Input validation schemas +const loginSchema = z.object({ + email: z.string().email('Invalid email address'), + password: z.string().min(8, 'Password must be at least 8 characters'), +}); + +const registerSchema = z.object({ + email: z.string().email('Invalid email address'), + name: z.string().min(2, 'Name must be at least 2 characters'), + password: z.string().min(8, 'Password must be at least 8 characters'), + organizationName: z.string().min(2, 'Organization name must be at least 2 characters').optional(), +}); + +/** + * POST /api/v1/auth/login + * Authenticate user with email and password + */ +router.post('/login', authLimiter, async (req, res) => { + try { + // Validate input + const validatedData = loginSchema.parse(req.body); + + // Attempt login + const { user, token } = await authService.login(validatedData); + + // Set HttpOnly cookie + res.cookie('auth_token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + path: '/', + }); + + // Log successful login + logger.info(`Successful login: ${user.email}`); + + res.json({ + success: true, + status: 200, + data: { + user, + token, // Also return token for API clients + }, + message: 'Login successful' + }); + + } catch (error) { + logger.warn('Login failed:', error); + + if (error instanceof z.ZodError) { + return res.status(400).json({ + success: false, + error: 'Validation error', + message: error.errors[0]?.message || 'Invalid input', + details: error.errors + }); + } + + res.status(400).json({ + success: false, + error: 'Login failed', + message: error instanceof Error ? error.message : 'Invalid credentials' + }); + } +}); + +/** + * POST /api/v1/auth/register + * Register a new user account + */ +router.post('/register', registerLimiter, async (req, res) => { + try { + // Validate input + const validatedData = registerSchema.parse(req.body); + + // Create user + const user = await authService.register(validatedData); + + // Log successful registration + logger.info(`New user registered: ${user.email}`); + + res.status(201).json({ + success: true, + status: 201, + data: { user }, + message: 'Registration successful' + }); + + } catch (error) { + logger.warn('Registration failed:', error); + + if (error instanceof z.ZodError) { + return res.status(400).json({ + success: false, + error: 'Validation error', + message: error.errors[0]?.message || 'Invalid input', + details: error.errors + }); + } + + const statusCode = error instanceof Error && error.message === 'User already exists' ? 409 : 400; + + res.status(statusCode).json({ + success: false, + error: 'Registration failed', + message: error instanceof Error ? error.message : 'Registration failed' + }); + } +}); + +/** + * POST /api/v1/auth/logout + * Clear authentication tokens + */ +router.post('/logout', (req, res) => { + // Clear cookie + res.clearCookie('auth_token', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/', + }); + + logger.info('User logged out'); + + res.json({ + success: true, + status: 200, + message: 'Logout successful' + }); +}); + +/** + * GET /api/v1/auth/me + * Get current user profile + */ +router.get('/me', requireAuth, (req: AuthenticatedRequest, res) => { + res.json({ + success: true, + status: 200, + data: { + user: req.user + } + }); +}); + +/** + * PUT /api/v1/auth/me + * Update current user profile + */ +router.put('/me', requireAuth, async (req: AuthenticatedRequest, res) => { + try { + const updateSchema = z.object({ + name: z.string().min(2).optional(), + email: z.string().email().optional(), + }); + + const validatedData = updateSchema.parse(req.body); + + // Update user profile (simplified - would need more validation in production) + // This is a placeholder for Phase 1 + logger.info(`Profile update requested by user: ${req.user!.email}`); + + res.json({ + success: true, + status: 200, + data: { + user: req.user + }, + message: 'Profile update will be implemented in a future phase' + }); + + } catch (error) { + if (error instanceof z.ZodError) { + return res.status(400).json({ + success: false, + error: 'Validation error', + message: error.errors[0]?.message || 'Invalid input', + details: error.errors + }); + } + + res.status(500).json({ + success: false, + error: 'Update failed', + message: 'Failed to update profile' + }); + } +}); + +/** + * POST /api/v1/auth/refresh + * Refresh authentication token + */ +router.post('/refresh', requireAuth, (req: AuthenticatedRequest, res) => { + try { + // Generate new token + const token = authService.generateToken(req.user!.id, req.user!.email); + + // Set new cookie + res.cookie('auth_token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days + path: '/', + }); + + res.json({ + success: true, + status: 200, + data: { token }, + message: 'Token refreshed successfully' + }); + + } catch (error) { + logger.error('Token refresh failed:', error); + res.status(500).json({ + success: false, + error: 'Refresh failed', + message: 'Failed to refresh token' + }); + } +}); + +export default router; diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts new file mode 100644 index 00000000..e6e35479 --- /dev/null +++ b/apps/api/src/services/auth.service.ts @@ -0,0 +1,326 @@ +/** + * Authentication Service for Redirect Intelligence v2 + * + * Handles user authentication, password hashing, and JWT token management + */ + +import argon2 from 'argon2'; +import jwt from 'jsonwebtoken'; +import { z } from 'zod'; +import { prisma } from '../lib/prisma'; +import { logger } from '../lib/logger'; + +// Input validation schemas +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +const registerSchema = z.object({ + email: z.string().email(), + name: z.string().min(2), + password: z.string().min(8), + organizationName: z.string().min(2).optional(), +}); + +export interface AuthUser { + id: string; + email: string; + name: string; + memberships: Array<{ + orgId: string; + role: string; + organization: { + name: string; + plan: string; + }; + }>; +} + +export interface AuthResult { + user: AuthUser; + token: string; +} + +export class AuthService { + private readonly JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-in-production'; + private readonly JWT_EXPIRES_IN = '7d'; + + /** + * Hash a password using Argon2 + */ + async hashPassword(password: string): Promise { + try { + return await argon2.hash(password, { + type: argon2.argon2id, + memoryCost: 2 ** 16, // 64 MB + timeCost: 3, + parallelism: 1, + }); + } catch (error) { + logger.error('Password hashing failed:', error); + throw new Error('Password hashing failed'); + } + } + + /** + * Verify a password against its hash + */ + async verifyPassword(hash: string, password: string): Promise { + try { + return await argon2.verify(hash, password); + } catch (error) { + logger.error('Password verification failed:', error); + return false; + } + } + + /** + * Generate a JWT token for a user + */ + generateToken(userId: string, email: string): string { + return jwt.sign( + { + userId, + email, + iat: Math.floor(Date.now() / 1000), + }, + this.JWT_SECRET, + { expiresIn: this.JWT_EXPIRES_IN } + ); + } + + /** + * Verify and decode a JWT token + */ + verifyToken(token: string): { userId: string; email: string } { + try { + const decoded = jwt.verify(token, this.JWT_SECRET) as { + userId: string; + email: string; + }; + return decoded; + } catch (error) { + logger.error('Token verification failed:', error); + throw new Error('Invalid token'); + } + } + + /** + * Login user with email and password + */ + async login(data: z.infer): Promise { + const { email, password } = loginSchema.parse(data); + + logger.info(`Login attempt for email: ${email}`); + + // Find user with memberships and organizations + const user = await prisma.user.findUnique({ + where: { email }, + include: { + memberships: { + include: { + organization: { + select: { + name: true, + plan: true, + } + } + } + } + } + }); + + if (!user) { + logger.warn(`Login failed: User not found for email ${email}`); + throw new Error('Invalid credentials'); + } + + // Verify password + const isValidPassword = await this.verifyPassword(user.passwordHash, password); + if (!isValidPassword) { + logger.warn(`Login failed: Invalid password for email ${email}`); + throw new Error('Invalid credentials'); + } + + // Update last login timestamp + await prisma.user.update({ + where: { id: user.id }, + data: { lastLoginAt: new Date() } + }); + + // Generate token + const token = this.generateToken(user.id, user.email); + + // Format user data for response + const authUser: AuthUser = { + id: user.id, + email: user.email, + name: user.name, + memberships: user.memberships.map(m => ({ + orgId: m.orgId, + role: m.role, + organization: { + name: m.organization.name, + plan: m.organization.plan, + } + })) + }; + + logger.info(`Login successful for user: ${user.email}`); + + return { user: authUser, token }; + } + + /** + * Register a new user + */ + async register(data: z.infer): Promise { + const { email, name, password, organizationName } = registerSchema.parse(data); + + logger.info(`Registration attempt for email: ${email}`); + + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { email } + }); + + if (existingUser) { + logger.warn(`Registration failed: User already exists for email ${email}`); + throw new Error('User already exists'); + } + + // Hash password + const passwordHash = await this.hashPassword(password); + + // Create user and organization in a transaction + const result = await prisma.$transaction(async (tx) => { + // Create user + const user = await tx.user.create({ + data: { + email, + name, + passwordHash, + } + }); + + // Create organization (or use default) + const organization = await tx.organization.create({ + data: { + name: organizationName || `${name}'s Organization`, + plan: 'free', + } + }); + + // Create membership + await tx.orgMembership.create({ + data: { + userId: user.id, + orgId: organization.id, + role: 'OWNER', + } + }); + + // Create default project + await tx.project.create({ + data: { + name: 'Default Project', + orgId: organization.id, + settingsJson: { + description: 'Default project for redirect tracking', + defaultMethod: 'GET', + }, + } + }); + + return { user, organization }; + }); + + logger.info(`Registration successful for user: ${email}`); + + return { + id: result.user.id, + email: result.user.email, + name: result.user.name, + memberships: [{ + orgId: result.organization.id, + role: 'OWNER', + organization: { + name: result.organization.name, + plan: result.organization.plan, + } + }] + }; + } + + /** + * Get user by ID with memberships + */ + async getUserById(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + include: { + memberships: { + include: { + organization: { + select: { + name: true, + plan: true, + } + } + } + } + } + }); + + if (!user) { + return null; + } + + return { + id: user.id, + email: user.email, + name: user.name, + memberships: user.memberships.map(m => ({ + orgId: m.orgId, + role: m.role, + organization: { + name: m.organization.name, + plan: m.organization.plan, + } + })) + }; + } + + /** + * Check if user has access to organization + */ + async hasOrgAccess(userId: string, orgId: string): Promise { + const membership = await prisma.orgMembership.findUnique({ + where: { + orgId_userId: { + orgId, + userId, + } + } + }); + + return !!membership; + } + + /** + * Get user's role in organization + */ + async getUserRole(userId: string, orgId: string): Promise { + const membership = await prisma.orgMembership.findUnique({ + where: { + orgId_userId: { + orgId, + userId, + } + } + }); + + return membership?.role || null; + } +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 00000000..d4c6197a --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Redirect Intelligence v2 + + +
+ + + diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 00000000..7fb2453e --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,86 @@ +/** + * Redirect Intelligence v2 - Frontend Entry Point + */ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +// Placeholder component for Phase 1 +function App() { + return ( +
+

๐Ÿš€ Redirect Intelligence v2

+

+ Phase 1: PostgreSQL + Prisma + Authentication is in progress. +

+ +
+

โœ… What's Working

+
    +
  • Docker Compose infrastructure
  • +
  • TypeScript API server
  • +
  • Backward compatible legacy endpoints
  • +
  • Database schema with Prisma
  • +
  • Authentication system (JWT + Argon2)
  • +
+
+ +
+

๐Ÿšง Coming Next

+
    +
  • Chakra UI frontend (Phase 4)
  • +
  • Enhanced redirect analysis (Phase 2-3)
  • +
  • Bulk processing (Phase 6)
  • +
  • Monitoring & alerts (Phase 10)
  • +
+
+ +
+

๐Ÿ”— API Endpoints

+

Test the API directly:

+
    +
  • /api/docs - API Documentation
  • +
  • /health - Health Check
  • +
  • POST /api/v1/auth/register - User Registration
  • +
  • POST /api/v1/auth/login - User Login
  • +
  • GET /api/v1/auth/me - User Profile
  • +
+
+ +
+

๐Ÿงช Test the Legacy Endpoints (100% Compatible)

+
+{`curl -X POST ${window.location.origin}/api/v1/track \\
+  -H "Content-Type: application/json" \\
+  -d '{"url": "github.com", "method": "GET"}'`}
+        
+
+
+ ); +} + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts new file mode 100644 index 00000000..5ebacb7f --- /dev/null +++ b/apps/worker/src/index.ts @@ -0,0 +1,67 @@ +/** + * Background Worker for Redirect Intelligence v2 + * + * Handles bulk jobs, monitoring, and other background tasks + */ + +import 'dotenv/config'; +import { Worker, Queue } from 'bullmq'; +import IORedis from 'ioredis'; + +const redis = new IORedis(process.env.REDIS_URL || 'redis://localhost:6379'); + +console.log('๐Ÿ”„ Redirect Intelligence v2 Worker starting...'); + +// Placeholder worker - will be implemented in later phases +const bulkQueue = new Queue('bulk-checks', { connection: redis }); +const monitoringQueue = new Queue('monitoring', { connection: redis }); + +const bulkWorker = new Worker('bulk-checks', async (job) => { + console.log('Processing bulk job:', job.id); + // Bulk processing logic will be implemented in Phase 6 + return { status: 'completed', message: 'Bulk job processing not yet implemented' }; +}, { connection: redis }); + +const monitoringWorker = new Worker('monitoring', async (job) => { + console.log('Processing monitoring job:', job.id); + // Monitoring logic will be implemented in Phase 10 + return { status: 'completed', message: 'Monitoring not yet implemented' }; +}, { connection: redis }); + +bulkWorker.on('completed', (job) => { + console.log(`โœ… Bulk job ${job.id} completed`); +}); + +bulkWorker.on('failed', (job, err) => { + console.error(`โŒ Bulk job ${job?.id} failed:`, err); +}); + +monitoringWorker.on('completed', (job) => { + console.log(`โœ… Monitoring job ${job.id} completed`); +}); + +monitoringWorker.on('failed', (job, err) => { + console.error(`โŒ Monitoring job ${job?.id} failed:`, err); +}); + +// Graceful shutdown +process.on('SIGTERM', async () => { + console.log('๐Ÿ›‘ Shutting down worker...'); + await bulkWorker.close(); + await monitoringWorker.close(); + await redis.quit(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('๐Ÿ›‘ Shutting down worker...'); + await bulkWorker.close(); + await monitoringWorker.close(); + await redis.quit(); + process.exit(0); +}); + +console.log('๐Ÿš€ Worker is ready to process jobs'); +console.log(`๐Ÿ“ก Connected to Redis: ${process.env.REDIS_URL || 'redis://localhost:6379'}`); + +export { bulkQueue, monitoringQueue }; diff --git a/packages/database/init.sql b/packages/database/init.sql new file mode 100644 index 00000000..1cbbdec5 --- /dev/null +++ b/packages/database/init.sql @@ -0,0 +1,12 @@ +-- Database initialization script for Redirect Intelligence v2 +-- This script runs when the PostgreSQL container starts + +-- Enable necessary extensions +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "citext"; + +-- Create database if it doesn't exist (handled by Docker environment) +-- The database 'redirect_intelligence' is created by Docker environment variables + +-- Grant necessary permissions +GRANT ALL PRIVILEGES ON DATABASE redirect_intelligence TO postgres; diff --git a/packages/database/package.json b/packages/database/package.json index a160129f..8683ef00 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -11,7 +11,8 @@ "db:reset": "prisma migrate reset" }, "dependencies": { - "@prisma/client": "^5.7.1" + "@prisma/client": "^5.7.1", + "argon2": "^0.31.2" }, "devDependencies": { "prisma": "^5.7.1", diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma new file mode 100644 index 00000000..b9b47c08 --- /dev/null +++ b/packages/database/prisma/schema.prisma @@ -0,0 +1,250 @@ +// Redirect Intelligence v2 - Database Schema +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" + output = "../../../node_modules/.prisma/client" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + email String @unique + name String + passwordHash String @map("password_hash") + createdAt DateTime @default(now()) @map("created_at") + lastLoginAt DateTime? @map("last_login_at") + + memberships OrgMembership[] + auditLogs AuditLog[] + + @@map("users") +} + +model Organization { + id String @id @default(cuid()) + name String + plan String @default("free") + createdAt DateTime @default(now()) @map("created_at") + + memberships OrgMembership[] + projects Project[] + apiKeys ApiKey[] + auditLogs AuditLog[] + + @@map("organizations") +} + +model OrgMembership { + id String @id @default(cuid()) + orgId String @map("org_id") + userId String @map("user_id") + role Role + + organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([orgId, userId]) + @@map("org_memberships") +} + +model Project { + id String @id @default(cuid()) + orgId String @map("org_id") + name String + settingsJson Json @map("settings_json") @default("{}") + createdAt DateTime @default(now()) @map("created_at") + + organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + checks Check[] + bulkJobs BulkJob[] + + @@map("projects") +} + +model Check { + id String @id @default(cuid()) + projectId String @map("project_id") + inputUrl String @map("input_url") + method String @default("GET") + headersJson Json @map("headers_json") @default("{}") + userAgent String? @map("user_agent") + startedAt DateTime @map("started_at") + finishedAt DateTime? @map("finished_at") + status CheckStatus + finalUrl String? @map("final_url") + totalTimeMs Int? @map("total_time_ms") + reportId String? @map("report_id") + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + hops Hop[] + sslInspections SslInspection[] + seoFlags SeoFlags? + securityFlags SecurityFlags? + reports Report[] + + @@index([projectId, startedAt(sort: Desc)]) + @@map("checks") +} + +model Hop { + id String @id @default(cuid()) + checkId String @map("check_id") + hopIndex Int @map("hop_index") + url String + scheme String? + statusCode Int? @map("status_code") + redirectType RedirectType @map("redirect_type") + latencyMs Int? @map("latency_ms") + contentType String? @map("content_type") + reason String? + responseHeadersJson Json @map("response_headers_json") @default("{}") + + check Check @relation(fields: [checkId], references: [id], onDelete: Cascade) + + @@index([checkId, hopIndex]) + @@map("hops") +} + +model SslInspection { + id String @id @default(cuid()) + checkId String @map("check_id") + host String + validFrom DateTime? @map("valid_from") + validTo DateTime? @map("valid_to") + daysToExpiry Int? @map("days_to_expiry") + issuer String? + protocol String? + warningsJson Json @map("warnings_json") @default("[]") + + check Check @relation(fields: [checkId], references: [id], onDelete: Cascade) + + @@map("ssl_inspections") +} + +model SeoFlags { + id String @id @default(cuid()) + checkId String @unique @map("check_id") + robotsTxtStatus String? @map("robots_txt_status") + robotsTxtRulesJson Json @map("robots_txt_rules_json") @default("{}") + metaRobots String? @map("meta_robots") + canonicalUrl String? @map("canonical_url") + sitemapPresent Boolean @default(false) @map("sitemap_present") + noindex Boolean @default(false) + nofollow Boolean @default(false) + + check Check @relation(fields: [checkId], references: [id], onDelete: Cascade) + + @@map("seo_flags") +} + +model SecurityFlags { + id String @id @default(cuid()) + checkId String @unique @map("check_id") + safeBrowsingStatus String? @map("safe_browsing_status") + mixedContent MixedContent @map("mixed_content") @default(NONE) + httpsToHttp Boolean @map("https_to_http") @default(false) + + check Check @relation(fields: [checkId], references: [id], onDelete: Cascade) + + @@map("security_flags") +} + +model Report { + id String @id @default(cuid()) + checkId String @map("check_id") + markdownPath String? @map("markdown_path") + pdfPath String? @map("pdf_path") + createdAt DateTime @default(now()) @map("created_at") + + check Check @relation(fields: [checkId], references: [id], onDelete: Cascade) + + @@map("reports") +} + +model BulkJob { + id String @id @default(cuid()) + projectId String @map("project_id") + uploadPath String @map("upload_path") + status JobStatus + progressJson Json @map("progress_json") @default("{}") + createdAt DateTime @default(now()) @map("created_at") + completedAt DateTime? @map("completed_at") + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@map("bulk_jobs") +} + +model ApiKey { + id String @id @default(cuid()) + orgId String @map("org_id") + name String + tokenHash String @unique @map("token_hash") + permsJson Json @map("perms_json") @default("{}") + rateLimitQuota Int @map("rate_limit_quota") @default(1000) + createdAt DateTime @default(now()) @map("created_at") + + organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + + @@index([tokenHash]) + @@map("api_keys") +} + +model AuditLog { + id String @id @default(cuid()) + orgId String @map("org_id") + actorUserId String? @map("actor_user_id") + action String + entity String + entityId String @map("entity_id") + metaJson Json @map("meta_json") @default("{}") + createdAt DateTime @default(now()) @map("created_at") + + organization Organization @relation(fields: [orgId], references: [id], onDelete: Cascade) + actor User? @relation(fields: [actorUserId], references: [id], onDelete: SetNull) + + @@map("audit_logs") +} + +enum Role { + OWNER + ADMIN + MEMBER +} + +enum CheckStatus { + OK + ERROR + TIMEOUT + LOOP +} + +enum RedirectType { + HTTP_301 + HTTP_302 + HTTP_307 + HTTP_308 + META_REFRESH + JS + FINAL + OTHER +} + +enum MixedContent { + NONE + PRESENT + FINAL_TO_HTTP +} + +enum JobStatus { + QUEUED + RUNNING + DONE + ERROR +} diff --git a/packages/database/prisma/seed.ts b/packages/database/prisma/seed.ts new file mode 100644 index 00000000..5c597349 --- /dev/null +++ b/packages/database/prisma/seed.ts @@ -0,0 +1,118 @@ +/** + * Database Seed Script for Redirect Intelligence v2 + * + * This script creates initial data for development and testing + */ + +import { PrismaClient, Role } from '@prisma/client'; +import argon2 from 'argon2'; + +const prisma = new PrismaClient(); + +async function main() { + console.log('๐ŸŒฑ Starting database seed...'); + + // Create demo user + const demoPassword = await argon2.hash('demo123456'); + + const demoUser = await prisma.user.upsert({ + where: { email: 'demo@redirectintelligence.com' }, + update: {}, + create: { + email: 'demo@redirectintelligence.com', + name: 'Demo User', + passwordHash: demoPassword, + }, + }); + + console.log('๐Ÿ‘ค Created demo user:', demoUser.email); + + // Create demo organization + const demoOrg = await prisma.organization.upsert({ + where: { id: 'demo-org' }, + update: {}, + create: { + id: 'demo-org', + name: 'Demo Organization', + plan: 'pro', + }, + }); + + console.log('๐Ÿข Created demo organization:', demoOrg.name); + + // Create membership + await prisma.orgMembership.upsert({ + where: { + orgId_userId: { + orgId: demoOrg.id, + userId: demoUser.id, + }, + }, + update: {}, + create: { + orgId: demoOrg.id, + userId: demoUser.id, + role: Role.OWNER, + }, + }); + + console.log('๐Ÿค Created organization membership'); + + // Create demo project + const demoProject = await prisma.project.upsert({ + where: { id: 'demo-project' }, + update: {}, + create: { + id: 'demo-project', + name: 'Demo Project', + orgId: demoOrg.id, + settingsJson: { + description: 'Demo project for testing redirect tracking', + defaultMethod: 'GET', + enableSSLAnalysis: true, + enableSEOAnalysis: true, + }, + }, + }); + + console.log('๐Ÿ“ Created demo project:', demoProject.name); + + // Create a default "anonymous" project for non-authenticated users + const anonymousProject = await prisma.project.upsert({ + where: { id: 'anonymous-project' }, + update: {}, + create: { + id: 'anonymous-project', + name: 'Anonymous Checks', + orgId: demoOrg.id, + settingsJson: { + description: 'Project for anonymous redirect checks', + public: true, + }, + }, + }); + + console.log('๐ŸŒ Created anonymous project:', anonymousProject.name); + + console.log('โœ… Database seed completed successfully!'); + console.log(` +๐Ÿ“ Demo Credentials: + Email: ${demoUser.email} + Password: demo123456 + +๐Ÿ”— You can now: + 1. Login with these credentials + 2. Create checks in the demo project + 3. Test API endpoints with authentication + `); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error('โŒ Error during seed:', e); + await prisma.$disconnect(); + process.exit(1); + });