feat(db): add PostgreSQL connection, migration runner, and seed data
- Add pool export and graceful shutdown hook to db/index.ts - Create migrate.ts — programmatic migration runner using drizzle-orm/migrator - Create seed.ts — idempotent seed script with sample users, subscriptions, watchlist items, exposures, alerts, blog posts, properties, and removal requests - Create db.test.ts — unit tests for db, migrate, and seed module exports - Add web/.env.example documenting DATABASE_URL - Add db:generate, db:push, db:migrate, db:seed scripts to web/package.json
This commit is contained in:
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -80,6 +80,9 @@ importers:
|
||||
jsdom:
|
||||
specifier: ^29.1.1
|
||||
version: 29.1.1
|
||||
tsx:
|
||||
specifier: ^4.22.3
|
||||
version: 4.22.3
|
||||
vite-plugin-solid:
|
||||
specifier: ^2.11.12
|
||||
version: 2.11.12(solid-js@1.9.13)(vite@7.3.3(@types/node@25.9.1)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))
|
||||
|
||||
1
web/.env.example
Normal file
1
web/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/shieldai"
|
||||
@@ -7,7 +7,11 @@
|
||||
"start": "vite start",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:migrate": "tsx src/server/db/migrate.ts",
|
||||
"db:seed": "tsx src/server/db/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
@@ -34,6 +38,7 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"jsdom": "^29.1.1",
|
||||
"tsx": "^4.22.3",
|
||||
"vite-plugin-solid": "^2.11.12",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
|
||||
23
web/src/server/db/db.test.ts
Normal file
23
web/src/server/db/db.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("db module", () => {
|
||||
it("exports db and pool", async () => {
|
||||
const mod = await import("./index");
|
||||
expect(mod.db).toBeDefined();
|
||||
expect(mod.pool).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrate module", () => {
|
||||
it("exports runMigrations function", async () => {
|
||||
const mod = await import("./migrate");
|
||||
expect(mod.runMigrations).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seed module", () => {
|
||||
it("exports seed function", async () => {
|
||||
const mod = await import("./seed");
|
||||
expect(mod.seed).toBeInstanceOf(Function);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,18 @@ import * as schema from "./schema";
|
||||
|
||||
const pool = new pg.Pool({
|
||||
connectionString: process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/shieldai",
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 10000,
|
||||
});
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
export { pool };
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
pool.end().catch(() => process.exit(1));
|
||||
});
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
pool.end().catch(() => process.exit(1));
|
||||
});
|
||||
|
||||
23
web/src/server/db/migrate.ts
Normal file
23
web/src/server/db/migrate.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { db, pool } from "./index";
|
||||
|
||||
export async function runMigrations() {
|
||||
console.log("[db] Running migrations...");
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||
console.log("[db] Migrations completed successfully");
|
||||
} catch (error) {
|
||||
console.error("[db] Migration failed:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const isMainModule = process.argv[1]?.includes("migrate");
|
||||
if (isMainModule) {
|
||||
runMigrations()
|
||||
.then(() => pool.end())
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
308
web/src/server/db/seed.ts
Normal file
308
web/src/server/db/seed.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { db, pool } from "./index";
|
||||
import {
|
||||
users,
|
||||
familyGroups,
|
||||
familyGroupMembers,
|
||||
subscriptions,
|
||||
watchlistItems,
|
||||
exposures,
|
||||
alerts,
|
||||
blogPosts,
|
||||
propertyWatchlistItems,
|
||||
infoBrokers,
|
||||
removalRequests,
|
||||
} from "./schema";
|
||||
|
||||
const ALICE_ID = "a0000000-0000-0000-0000-000000000001";
|
||||
const BOB_ID = "a0000000-0000-0000-0000-000000000002";
|
||||
const CAROL_ID = "a0000000-0000-0000-0000-000000000003";
|
||||
const FAMILY_GROUP_ID = "a0000000-0000-0000-0000-000000000010";
|
||||
const ALICE_SUB_ID = "a0000000-0000-0000-0000-000000000020";
|
||||
const BOB_SUB_ID = "a0000000-0000-0000-0000-000000000021";
|
||||
const WATCH_EMAIL_ALICE = "a0000000-0000-0000-0000-000000000030";
|
||||
const WATCH_PHONE_ALICE = "a0000000-0000-0000-0000-000000000031";
|
||||
const WATCH_EMAIL_BOB = "a0000000-0000-0000-0000-000000000032";
|
||||
const WATCH_SSN_BOB = "a0000000-0000-0000-0000-000000000033";
|
||||
const WATCH_DOMAIN_BOB = "a0000000-0000-0000-0000-000000000034";
|
||||
const EXPOSURE_1 = "a0000000-0000-0000-0000-000000000040";
|
||||
const EXPOSURE_2 = "a0000000-0000-0000-0000-000000000041";
|
||||
const EXPOSURE_3 = "a0000000-0000-0000-0000-000000000042";
|
||||
const PROPERTY_1 = "a0000000-0000-0000-0000-000000000050";
|
||||
const PROPERTY_2 = "a0000000-0000-0000-0000-000000000051";
|
||||
const BROKER_ID = "a0000000-0000-0000-0000-000000000060";
|
||||
const REMOVAL_REQUEST_ID = "a0000000-0000-0000-0000-000000000070";
|
||||
|
||||
export async function seed() {
|
||||
console.log("[seed] Seeding database...");
|
||||
|
||||
await db.insert(users).values([
|
||||
{ id: ALICE_ID, email: "alice@example.com", name: "Alice Smith", role: "family_admin", emailVerified: new Date() },
|
||||
{ id: BOB_ID, email: "bob@example.com", name: "Bob Smith", role: "user", emailVerified: new Date() },
|
||||
{ id: CAROL_ID, email: "carol@example.com", name: "Carol Johnson", role: "user" },
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Users created");
|
||||
|
||||
await db.insert(familyGroups).values([
|
||||
{ id: FAMILY_GROUP_ID, name: "Smith Family", ownerId: ALICE_ID },
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Family group created");
|
||||
|
||||
await db.insert(familyGroupMembers).values([
|
||||
{ groupId: FAMILY_GROUP_ID, userId: ALICE_ID, role: "owner" },
|
||||
{ groupId: FAMILY_GROUP_ID, userId: BOB_ID, role: "member" },
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Family group members created");
|
||||
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
await db.insert(subscriptions).values([
|
||||
{
|
||||
id: ALICE_SUB_ID,
|
||||
userId: ALICE_ID,
|
||||
familyGroupId: FAMILY_GROUP_ID,
|
||||
tier: "premium",
|
||||
status: "active",
|
||||
currentPeriodStart: periodStart,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
{
|
||||
id: BOB_SUB_ID,
|
||||
userId: BOB_ID,
|
||||
tier: "basic",
|
||||
status: "active",
|
||||
currentPeriodStart: periodStart,
|
||||
currentPeriodEnd: periodEnd,
|
||||
},
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Subscriptions created");
|
||||
|
||||
await db.insert(watchlistItems).values([
|
||||
{ id: WATCH_EMAIL_ALICE, subscriptionId: ALICE_SUB_ID, type: "email", value: "alice@example.com", hash: "hash-alice-email" },
|
||||
{ id: WATCH_PHONE_ALICE, subscriptionId: ALICE_SUB_ID, type: "phoneNumber", value: "+15551234567", hash: "hash-alice-phone" },
|
||||
{ id: WATCH_EMAIL_BOB, subscriptionId: BOB_SUB_ID, type: "email", value: "bob@example.com", hash: "hash-bob-email" },
|
||||
{ id: WATCH_SSN_BOB, subscriptionId: BOB_SUB_ID, type: "ssn", value: "XXX-XX-1234", hash: "hash-bob-ssn" },
|
||||
{ id: WATCH_DOMAIN_BOB, subscriptionId: BOB_SUB_ID, type: "domain", value: "bobsmith.example.com", hash: "hash-bob-domain" },
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Watchlist items created");
|
||||
|
||||
const pastDate = (daysAgo: number) => new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
|
||||
await db.insert(exposures).values([
|
||||
{
|
||||
id: EXPOSURE_1,
|
||||
subscriptionId: ALICE_SUB_ID,
|
||||
watchlistItemId: WATCH_EMAIL_ALICE,
|
||||
source: "hibp",
|
||||
dataType: "email",
|
||||
identifier: "alice@example.com",
|
||||
identifierHash: "hash-alice-email",
|
||||
severity: "critical",
|
||||
metadata: { breach: "Adobe 2013", dataClasses: ["email", "password_hint", "password"] },
|
||||
isFirstTime: true,
|
||||
detectedAt: pastDate(30),
|
||||
},
|
||||
{
|
||||
id: EXPOSURE_2,
|
||||
subscriptionId: ALICE_SUB_ID,
|
||||
watchlistItemId: WATCH_PHONE_ALICE,
|
||||
source: "darkWebForum",
|
||||
dataType: "phoneNumber",
|
||||
identifier: "+15551234567",
|
||||
identifierHash: "hash-alice-phone",
|
||||
severity: "warning",
|
||||
metadata: { forum: "Nulled.to", postedAt: "2025-04-01" },
|
||||
detectedAt: pastDate(14),
|
||||
},
|
||||
{
|
||||
id: EXPOSURE_3,
|
||||
subscriptionId: BOB_SUB_ID,
|
||||
watchlistItemId: WATCH_EMAIL_BOB,
|
||||
source: "hibp",
|
||||
dataType: "email",
|
||||
identifier: "bob@example.com",
|
||||
identifierHash: "hash-bob-email",
|
||||
severity: "info",
|
||||
metadata: { breach: "LinkedIn 2021", dataClasses: ["email", "name"] },
|
||||
detectedAt: pastDate(7),
|
||||
},
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Exposures created");
|
||||
|
||||
await db.insert(alerts).values([
|
||||
{
|
||||
subscriptionId: ALICE_SUB_ID,
|
||||
userId: ALICE_ID,
|
||||
exposureId: EXPOSURE_1,
|
||||
type: "exposure_detected",
|
||||
title: "Critical breach detected: Adobe 2013",
|
||||
message: "Your email alice@example.com was found in the Adobe 2013 data breach.",
|
||||
severity: "critical",
|
||||
channel: ["email", "push"],
|
||||
createdAt: pastDate(30),
|
||||
},
|
||||
{
|
||||
subscriptionId: ALICE_SUB_ID,
|
||||
userId: ALICE_ID,
|
||||
exposureId: EXPOSURE_2,
|
||||
type: "exposure_detected",
|
||||
title: "Phone number found on dark web forum",
|
||||
message: "Your phone number was posted on Nulled.to.",
|
||||
severity: "warning",
|
||||
channel: ["push"],
|
||||
createdAt: pastDate(14),
|
||||
},
|
||||
{
|
||||
subscriptionId: ALICE_SUB_ID,
|
||||
userId: ALICE_ID,
|
||||
type: "scan_complete",
|
||||
title: "Dark web scan completed",
|
||||
message: "Weekly dark web scan found 2 new exposures.",
|
||||
severity: "info",
|
||||
channel: ["email"],
|
||||
createdAt: pastDate(1),
|
||||
},
|
||||
{
|
||||
subscriptionId: BOB_SUB_ID,
|
||||
userId: BOB_ID,
|
||||
exposureId: EXPOSURE_3,
|
||||
type: "exposure_detected",
|
||||
title: "Breach detected: LinkedIn 2021",
|
||||
message: "Your email bob@example.com was found in the LinkedIn 2021 breach.",
|
||||
severity: "info",
|
||||
channel: ["email", "push"],
|
||||
createdAt: pastDate(7),
|
||||
},
|
||||
{
|
||||
subscriptionId: BOB_SUB_ID,
|
||||
userId: BOB_ID,
|
||||
type: "scan_complete",
|
||||
title: "Monthly scan complete",
|
||||
message: "Your monthly dark web scan has finished. 1 exposure found.",
|
||||
severity: "info",
|
||||
channel: ["email"],
|
||||
createdAt: pastDate(0),
|
||||
},
|
||||
{
|
||||
subscriptionId: ALICE_SUB_ID,
|
||||
userId: ALICE_ID,
|
||||
type: "exposure_resolved",
|
||||
title: "Exposure resolved: Adobe 2013",
|
||||
message: "The Adobe 2013 breach exposure has been marked as resolved.",
|
||||
severity: "info",
|
||||
channel: ["push"],
|
||||
createdAt: pastDate(0),
|
||||
},
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Alerts created");
|
||||
|
||||
await db.insert(blogPosts).values([
|
||||
{
|
||||
slug: "what-is-dark-web-monitoring",
|
||||
title: "What Is Dark Web Monitoring and Why You Need It",
|
||||
excerpt: "Learn how dark web monitoring protects your personal information from cybercriminals.",
|
||||
content: "The dark web is a hidden part of the internet where cybercriminals buy and sell stolen data. ShieldAI helps you monitor your digital footprint...",
|
||||
authorName: "ShieldAI Team",
|
||||
tags: ["dark-web", "monitoring", "security"],
|
||||
published: true,
|
||||
publishedAt: pastDate(60),
|
||||
viewCount: 1250,
|
||||
},
|
||||
{
|
||||
slug: "protect-your-family-online",
|
||||
title: "5 Tips to Protect Your Family's Online Privacy",
|
||||
excerpt: "Simple steps to keep your family's personal information safe from data brokers.",
|
||||
content: "In today's digital age, protecting your family's privacy is more important than ever. Here are five actionable tips...",
|
||||
authorName: "ShieldAI Team",
|
||||
tags: ["family", "privacy", "tips"],
|
||||
published: true,
|
||||
publishedAt: pastDate(30),
|
||||
viewCount: 820,
|
||||
},
|
||||
{
|
||||
slug: "understanding-data-brokers",
|
||||
title: "Understanding Data Brokers: Who Has Your Information?",
|
||||
excerpt: "A comprehensive guide to data brokers and how to opt out of their databases.",
|
||||
content: "Data brokers collect and sell personal information. This guide explains how they operate and how you can remove your data...",
|
||||
authorName: "ShieldAI Team",
|
||||
tags: ["data-brokers", "privacy", "opt-out"],
|
||||
published: true,
|
||||
publishedAt: pastDate(7),
|
||||
viewCount: 340,
|
||||
},
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Blog posts created");
|
||||
|
||||
await db.insert(propertyWatchlistItems).values([
|
||||
{
|
||||
id: PROPERTY_1,
|
||||
subscriptionId: ALICE_SUB_ID,
|
||||
address: "123 Main St, Springfield, IL 62701",
|
||||
parcelId: "SPR-1234-5678",
|
||||
ownerName: "Alice Smith",
|
||||
streetAddress: "123 Main St",
|
||||
city: "Springfield",
|
||||
state: "IL",
|
||||
zipCode: "62701",
|
||||
latitude: 39.7817,
|
||||
longitude: -89.6501,
|
||||
},
|
||||
{
|
||||
id: PROPERTY_2,
|
||||
subscriptionId: ALICE_SUB_ID,
|
||||
address: "456 Oak Ave, Springfield, IL 62704",
|
||||
parcelId: "SPR-8765-4321",
|
||||
ownerName: "Alice Smith",
|
||||
streetAddress: "456 Oak Ave",
|
||||
city: "Springfield",
|
||||
state: "IL",
|
||||
zipCode: "62704",
|
||||
latitude: 39.7654,
|
||||
longitude: -89.6321,
|
||||
},
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Property watchlist items created");
|
||||
|
||||
await db.insert(infoBrokers).values([
|
||||
{
|
||||
id: BROKER_ID,
|
||||
name: "PeopleFind Pro",
|
||||
domain: "peoplefindpro.example.com",
|
||||
category: "PEOPLE_SEARCH",
|
||||
removalMethod: "MANUAL_FORM",
|
||||
removalUrl: "https://peoplefindpro.example.com/opt-out",
|
||||
requiresAccount: true,
|
||||
requiresVerification: true,
|
||||
estimatedDays: 30,
|
||||
},
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Info brokers created");
|
||||
|
||||
await db.insert(removalRequests).values([
|
||||
{
|
||||
id: REMOVAL_REQUEST_ID,
|
||||
subscriptionId: ALICE_SUB_ID,
|
||||
brokerId: BROKER_ID,
|
||||
status: "PENDING",
|
||||
personalInfo: { name: "Alice Smith", email: "alice@example.com", phone: "+15551234567" },
|
||||
method: "MANUAL_FORM",
|
||||
attempts: 2,
|
||||
nextRetryAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000),
|
||||
notes: "Previous attempts failed due to CAPTCHA",
|
||||
},
|
||||
]).onConflictDoNothing();
|
||||
console.log("[seed] Removal requests created");
|
||||
|
||||
console.log("[seed] Seeding complete");
|
||||
}
|
||||
|
||||
const isMainModule = process.argv[1]?.includes("seed");
|
||||
if (isMainModule) {
|
||||
seed()
|
||||
.then(() => pool.end())
|
||||
.catch((error) => {
|
||||
console.error("[seed] Error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user