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:
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