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:
|
jsdom:
|
||||||
specifier: ^29.1.1
|
specifier: ^29.1.1
|
||||||
version: 29.1.1
|
version: 29.1.1
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.22.3
|
||||||
|
version: 4.22.3
|
||||||
vite-plugin-solid:
|
vite-plugin-solid:
|
||||||
specifier: ^2.11.12
|
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))
|
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",
|
"start": "vite start",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"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": {
|
"dependencies": {
|
||||||
"@solidjs/meta": "^0.29.4",
|
"@solidjs/meta": "^0.29.4",
|
||||||
@@ -34,6 +38,7 @@
|
|||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"tsx": "^4.22.3",
|
||||||
"vite-plugin-solid": "^2.11.12",
|
"vite-plugin-solid": "^2.11.12",
|
||||||
"vitest": "^4.1.5"
|
"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({
|
const pool = new pg.Pool({
|
||||||
connectionString: process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/shieldai",
|
connectionString: process.env.DATABASE_URL ?? "postgresql://postgres:postgres@localhost:5432/shieldai",
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
connectionTimeoutMillis: 10000,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const db = drizzle(pool, { schema });
|
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