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:
2026-05-25 15:39:20 -04:00
parent bc20aeaeb6
commit 052e08c17b
7 changed files with 376 additions and 1 deletions

3
pnpm-lock.yaml generated
View File

@@ -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
View File

@@ -0,0 +1 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/shieldai"

View File

@@ -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"
}

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

View File

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

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