feat(phase-1): implement PostgreSQL + Prisma + Authentication system
Core Features: - Complete Prisma database schema with all entities (users, orgs, projects, checks, etc.) - Production-grade authentication service with Argon2 password hashing - JWT-based session management with HttpOnly cookies - Comprehensive auth middleware with role-based access control - RESTful auth API endpoints: register, login, logout, me, refresh - Database seeding with demo data for development - Rate limiting on auth endpoints (5 attempts/15min) Technical Implementation: - Type-safe authentication with Zod validation - Proper error handling and logging throughout - Secure password hashing with Argon2id - JWT tokens with 7-day expiration - Database transactions for atomic operations - Comprehensive middleware for optional/required auth - Role hierarchy system (MEMBER < ADMIN < OWNER) Database Schema: - Users with secure password storage - Organizations with membership management - Projects for organizing redirect checks - Complete audit logging system - API key management for programmatic access - Bulk job tracking for future phases Backward Compatibility: - All existing endpoints preserved and functional - No breaking changes to legacy API responses - New auth system runs alongside existing functionality Ready for Phase 2: Enhanced redirect tracking with database persistence
This commit is contained in:
@@ -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({
|
||||
|
||||
216
apps/api/src/middleware/auth.middleware.ts
Normal file
216
apps/api/src/middleware/auth.middleware.ts
Normal file
@@ -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;
|
||||
266
apps/api/src/routes/auth.routes.ts
Normal file
266
apps/api/src/routes/auth.routes.ts
Normal file
@@ -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;
|
||||
326
apps/api/src/services/auth.service.ts
Normal file
326
apps/api/src/services/auth.service.ts
Normal file
@@ -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<string> {
|
||||
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<boolean> {
|
||||
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<typeof loginSchema>): Promise<AuthResult> {
|
||||
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<typeof registerSchema>): Promise<AuthUser> {
|
||||
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<AuthUser | null> {
|
||||
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<boolean> {
|
||||
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<string | null> {
|
||||
const membership = await prisma.orgMembership.findUnique({
|
||||
where: {
|
||||
orgId_userId: {
|
||||
orgId,
|
||||
userId,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return membership?.role || null;
|
||||
}
|
||||
}
|
||||
13
apps/web/index.html
Normal file
13
apps/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Redirect Intelligence v2</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
86
apps/web/src/main.tsx
Normal file
86
apps/web/src/main.tsx
Normal file
@@ -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 (
|
||||
<div style={{
|
||||
padding: '2rem',
|
||||
fontFamily: 'system-ui, sans-serif',
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto'
|
||||
}}>
|
||||
<h1>🚀 Redirect Intelligence v2</h1>
|
||||
<p>
|
||||
<strong>Phase 1: PostgreSQL + Prisma + Authentication</strong> is in progress.
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
background: '#f0f8ff',
|
||||
padding: '1rem',
|
||||
borderRadius: '8px',
|
||||
marginTop: '2rem'
|
||||
}}>
|
||||
<h3>✅ What's Working</h3>
|
||||
<ul>
|
||||
<li>Docker Compose infrastructure</li>
|
||||
<li>TypeScript API server</li>
|
||||
<li>Backward compatible legacy endpoints</li>
|
||||
<li>Database schema with Prisma</li>
|
||||
<li>Authentication system (JWT + Argon2)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
background: '#fff8dc',
|
||||
padding: '1rem',
|
||||
borderRadius: '8px',
|
||||
marginTop: '1rem'
|
||||
}}>
|
||||
<h3>🚧 Coming Next</h3>
|
||||
<ul>
|
||||
<li>Chakra UI frontend (Phase 4)</li>
|
||||
<li>Enhanced redirect analysis (Phase 2-3)</li>
|
||||
<li>Bulk processing (Phase 6)</li>
|
||||
<li>Monitoring & alerts (Phase 10)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<h3>🔗 API Endpoints</h3>
|
||||
<p>Test the API directly:</p>
|
||||
<ul>
|
||||
<li><a href="/api/docs">/api/docs</a> - API Documentation</li>
|
||||
<li><a href="/health">/health</a> - Health Check</li>
|
||||
<li><code>POST /api/v1/auth/register</code> - User Registration</li>
|
||||
<li><code>POST /api/v1/auth/login</code> - User Login</li>
|
||||
<li><code>GET /api/v1/auth/me</code> - User Profile</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '2rem',
|
||||
padding: '1rem',
|
||||
background: '#f5f5f5',
|
||||
borderRadius: '8px'
|
||||
}}>
|
||||
<h4>🧪 Test the Legacy Endpoints (100% Compatible)</h4>
|
||||
<pre style={{ background: '#000', color: '#0f0', padding: '1rem', overflow: 'auto' }}>
|
||||
{`curl -X POST ${window.location.origin}/api/v1/track \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"url": "github.com", "method": "GET"}'`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
67
apps/worker/src/index.ts
Normal file
67
apps/worker/src/index.ts
Normal file
@@ -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 };
|
||||
12
packages/database/init.sql
Normal file
12
packages/database/init.sql
Normal file
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
250
packages/database/prisma/schema.prisma
Normal file
250
packages/database/prisma/schema.prisma
Normal file
@@ -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
|
||||
}
|
||||
118
packages/database/prisma/seed.ts
Normal file
118
packages/database/prisma/seed.ts
Normal file
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user