Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import dotenv from 'dotenv';
|
||||
import { defineConfig } from 'drizzle-kit';
|
||||
|
||||
const _dir = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(_dir, '../../.env') });
|
||||
|
||||
export default defineConfig({
|
||||
schema: './src/schema/index.ts',
|
||||
out: './migrations',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
CREATE TABLE IF NOT EXISTS "audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid,
|
||||
"firm_id" uuid,
|
||||
"action" text NOT NULL,
|
||||
"meta" text,
|
||||
"ip" "inet",
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "email_verifications" (
|
||||
"token_hash" text PRIMARY KEY NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"consumed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "login_attempts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"ip" "inet",
|
||||
"success" boolean DEFAULT false NOT NULL,
|
||||
"attempted_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "password_resets" (
|
||||
"token_hash" text PRIMARY KEY NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"consumed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"ip" "inet",
|
||||
"user_agent" text,
|
||||
"last_seen_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"firm_id" uuid,
|
||||
"email" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"full_name" text,
|
||||
"role" text DEFAULT 'owner' NOT NULL,
|
||||
"email_verified_at" timestamp with time zone,
|
||||
"totp_secret_enc" text,
|
||||
"totp_enabled" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "firms" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"plan" text DEFAULT 'starter' NOT NULL,
|
||||
"watermark_enabled" boolean DEFAULT true NOT NULL,
|
||||
"storage_bytes_used" integer DEFAULT 0 NOT NULL,
|
||||
"stripe_customer_id" text,
|
||||
"stripe_subscription_id" text,
|
||||
"trial_ends_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "clients" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"firm_id" uuid NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text,
|
||||
"phone" text,
|
||||
"address" text,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "cases" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"firm_id" uuid NOT NULL,
|
||||
"client_id" uuid NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"case_number" text,
|
||||
"status" text DEFAULT 'open' NOT NULL,
|
||||
"practice_area" text,
|
||||
"description" text,
|
||||
"hourly_rate" numeric(10, 2),
|
||||
"opened_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"closed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "time_entries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"firm_id" uuid NOT NULL,
|
||||
"case_id" uuid NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"started_at" timestamp with time zone NOT NULL,
|
||||
"ended_at" timestamp with time zone,
|
||||
"minutes" integer DEFAULT 0 NOT NULL,
|
||||
"rate" numeric(10, 2) NOT NULL,
|
||||
"billable" boolean DEFAULT true NOT NULL,
|
||||
"invoice_item_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"firm_id" uuid NOT NULL,
|
||||
"case_id" uuid,
|
||||
"uploaded_by" uuid,
|
||||
"name" text NOT NULL,
|
||||
"storage_key" text NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"size_bytes" integer NOT NULL,
|
||||
"version" integer DEFAULT 1 NOT NULL,
|
||||
"parent_id" uuid,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "invoice_items" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"invoice_id" uuid NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"quantity" numeric(10, 2) DEFAULT '1' NOT NULL,
|
||||
"rate" numeric(10, 2) NOT NULL,
|
||||
"amount" numeric(12, 2) NOT NULL,
|
||||
"sort_order" integer DEFAULT 0 NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "invoices" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"firm_id" uuid NOT NULL,
|
||||
"client_id" uuid NOT NULL,
|
||||
"case_id" uuid,
|
||||
"number" text NOT NULL,
|
||||
"status" text DEFAULT 'draft' NOT NULL,
|
||||
"subtotal" numeric(12, 2) DEFAULT '0' NOT NULL,
|
||||
"tax_rate" numeric(5, 2) DEFAULT '0' NOT NULL,
|
||||
"total" numeric(12, 2) DEFAULT '0' NOT NULL,
|
||||
"notes" text,
|
||||
"issued_at" timestamp with time zone,
|
||||
"due_at" timestamp with time zone,
|
||||
"paid_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "contact_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"full_name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"message" text NOT NULL,
|
||||
"ip" "inet",
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "tool_usage" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"tool" text NOT NULL,
|
||||
"session_id" text,
|
||||
"ip" "inet",
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_firm_id_firms_id_fk" FOREIGN KEY ("firm_id") REFERENCES "public"."firms"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "email_verifications" ADD CONSTRAINT "email_verifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "password_resets" ADD CONSTRAINT "password_resets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_firm_id_firms_id_fk" FOREIGN KEY ("firm_id") REFERENCES "public"."firms"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "clients_firm_id_firms_id_fk" FOREIGN KEY ("firm_id") REFERENCES "public"."firms"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "cases" ADD CONSTRAINT "cases_firm_id_firms_id_fk" FOREIGN KEY ("firm_id") REFERENCES "public"."firms"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "cases" ADD CONSTRAINT "cases_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_firm_id_firms_id_fk" FOREIGN KEY ("firm_id") REFERENCES "public"."firms"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "time_entries" ADD CONSTRAINT "time_entries_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_firm_id_firms_id_fk" FOREIGN KEY ("firm_id") REFERENCES "public"."firms"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_uploaded_by_users_id_fk" FOREIGN KEY ("uploaded_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "invoice_items" ADD CONSTRAINT "invoice_items_invoice_id_invoices_id_fk" FOREIGN KEY ("invoice_id") REFERENCES "public"."invoices"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_firm_id_firms_id_fk" FOREIGN KEY ("firm_id") REFERENCES "public"."firms"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_client_id_clients_id_fk" FOREIGN KEY ("client_id") REFERENCES "public"."clients"("id") ON DELETE restrict ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "invoices" ADD CONSTRAINT "invoices_case_id_cases_id_fk" FOREIGN KEY ("case_id") REFERENCES "public"."cases"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "audit_firm_idx" ON "audit_log" USING btree ("firm_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "login_attempts_email_idx" ON "login_attempts" USING btree ("email","attempted_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "login_attempts_ip_idx" ON "login_attempts" USING btree ("ip","attempted_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "sessions_expires_idx" ON "sessions" USING btree ("expires_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "users_firm_idx" ON "users" USING btree ("firm_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "clients_firm_idx" ON "clients" USING btree ("firm_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cases_firm_idx" ON "cases" USING btree ("firm_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cases_client_idx" ON "cases" USING btree ("client_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "cases_status_idx" ON "cases" USING btree ("firm_id","status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "time_firm_idx" ON "time_entries" USING btree ("firm_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "time_case_idx" ON "time_entries" USING btree ("case_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "time_user_idx" ON "time_entries" USING btree ("user_id","started_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "time_unbilled_idx" ON "time_entries" USING btree ("firm_id","invoice_item_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "docs_firm_idx" ON "documents" USING btree ("firm_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "docs_case_idx" ON "documents" USING btree ("case_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "invoice_items_invoice_idx" ON "invoice_items" USING btree ("invoice_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "invoices_firm_idx" ON "invoices" USING btree ("firm_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "invoices_status_idx" ON "invoices" USING btree ("firm_id","status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tool_usage_tool_idx" ON "tool_usage" USING btree ("tool","created_at");
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "users" ADD COLUMN "is_superadmin" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "is_suspended" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "last_seen_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "contact_messages" ADD COLUMN "resolved_at" timestamp with time zone;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1777165783297,
|
||||
"tag": "0000_clear_wallop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1777169307877,
|
||||
"tag": "0001_orange_jamie_braddock",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@lawdesk/db",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./schema": "./src/schema/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json --noEmit",
|
||||
"generate": "drizzle-kit generate",
|
||||
"migrate": "tsx src/migrate.ts",
|
||||
"studio": "drizzle-kit studio",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"pg": "^8.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.10",
|
||||
"drizzle-kit": "^0.28.1",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import fs from 'node:fs';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import pg from 'pg';
|
||||
import * as schema from './schema';
|
||||
|
||||
export * from './schema';
|
||||
export { schema };
|
||||
|
||||
let _pool: pg.Pool | null = null;
|
||||
let _db: ReturnType<typeof drizzle<typeof schema>> | null = null;
|
||||
|
||||
function stripSslmode(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
u.searchParams.delete('sslmode');
|
||||
u.searchParams.delete('ssl');
|
||||
return u.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function getPool(): pg.Pool {
|
||||
if (_pool) return _pool;
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error('DATABASE_URL is not set');
|
||||
}
|
||||
|
||||
const caPath = process.env.DATABASE_CA_CERT_PATH;
|
||||
const ca = caPath && fs.existsSync(caPath) ? fs.readFileSync(caPath, 'utf8') : undefined;
|
||||
|
||||
// Strip sslmode from the URL so our explicit `ssl` option fully controls TLS behavior.
|
||||
// Without this, pg merges URL-derived settings and may force cert verification even when
|
||||
// we want to fall back to TLS-without-verification (no CA cert available).
|
||||
const ssl: pg.PoolConfig['ssl'] = ca
|
||||
? { ca, rejectUnauthorized: true }
|
||||
: { rejectUnauthorized: false };
|
||||
|
||||
_pool = new pg.Pool({
|
||||
connectionString: stripSslmode(connectionString),
|
||||
max: Number(process.env.DATABASE_POOL_MAX ?? 5),
|
||||
ssl,
|
||||
});
|
||||
|
||||
return _pool;
|
||||
}
|
||||
|
||||
export function getDb() {
|
||||
if (_db) return _db;
|
||||
_db = drizzle(getPool(), { schema });
|
||||
return _db;
|
||||
}
|
||||
|
||||
export type Database = ReturnType<typeof getDb>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { getDb, getPool } from './index';
|
||||
|
||||
async function main() {
|
||||
console.log('Running migrations...');
|
||||
await migrate(getDb(), { migrationsFolder: path.resolve(__dirname, '../migrations') });
|
||||
console.log('Migrations complete.');
|
||||
await getPool().end();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { pgTable, uuid, text, timestamp, inet, boolean, index } from 'drizzle-orm/pg-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
import { firms } from './firms';
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
firmId: uuid('firm_id').references(() => firms.id, { onDelete: 'set null' }),
|
||||
email: text('email').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
fullName: text('full_name'),
|
||||
role: text('role', { enum: ['owner', 'attorney', 'paralegal', 'staff'] })
|
||||
.notNull()
|
||||
.default('owner'),
|
||||
isSuperadmin: boolean('is_superadmin').notNull().default(false),
|
||||
isSuspended: boolean('is_suspended').notNull().default(false),
|
||||
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
|
||||
totpSecretEnc: text('totp_secret_enc'),
|
||||
totpEnabled: boolean('totp_enabled').notNull().default(false),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
firmIdx: index('users_firm_idx').on(t.firmId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const usersRelations = relations(users, ({ one }) => ({
|
||||
firm: one(firms, { fields: [users.firmId], references: [firms.id] }),
|
||||
}));
|
||||
|
||||
export const sessions = pgTable(
|
||||
'sessions',
|
||||
{
|
||||
// Hash of the cookie token (sha256). Cookie holds the raw token; DB stores the hash.
|
||||
id: text('id').primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
ip: inet('ip'),
|
||||
userAgent: text('user_agent'),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index('sessions_user_idx').on(t.userId),
|
||||
expiresIdx: index('sessions_expires_idx').on(t.expiresAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const emailVerifications = pgTable('email_verifications', {
|
||||
tokenHash: text('token_hash').primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
consumedAt: timestamp('consumed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const passwordResets = pgTable('password_resets', {
|
||||
tokenHash: text('token_hash').primaryKey(),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
consumedAt: timestamp('consumed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const loginAttempts = pgTable(
|
||||
'login_attempts',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
email: text('email').notNull(),
|
||||
ip: inet('ip'),
|
||||
success: boolean('success').notNull().default(false),
|
||||
attemptedAt: timestamp('attempted_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
emailIdx: index('login_attempts_email_idx').on(t.email, t.attemptedAt),
|
||||
ipIdx: index('login_attempts_ip_idx').on(t.ip, t.attemptedAt),
|
||||
}),
|
||||
);
|
||||
|
||||
export const auditLog = pgTable(
|
||||
'audit_log',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
firmId: uuid('firm_id').references(() => firms.id, { onDelete: 'set null' }),
|
||||
action: text('action').notNull(),
|
||||
meta: text('meta'),
|
||||
ip: inet('ip'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
firmIdx: index('audit_firm_idx').on(t.firmId, t.createdAt),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { pgTable, uuid, text, timestamp, numeric, index } from 'drizzle-orm/pg-core';
|
||||
import { firms } from './firms';
|
||||
import { clients } from './clients';
|
||||
|
||||
export const cases = pgTable(
|
||||
'cases',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
firmId: uuid('firm_id')
|
||||
.notNull()
|
||||
.references(() => firms.id, { onDelete: 'cascade' }),
|
||||
clientId: uuid('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'restrict' }),
|
||||
title: text('title').notNull(),
|
||||
caseNumber: text('case_number'),
|
||||
status: text('status', { enum: ['open', 'pending', 'closed', 'archived'] })
|
||||
.notNull()
|
||||
.default('open'),
|
||||
practiceArea: text('practice_area'),
|
||||
description: text('description'),
|
||||
hourlyRate: numeric('hourly_rate', { precision: 10, scale: 2 }),
|
||||
openedAt: timestamp('opened_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
closedAt: timestamp('closed_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
firmIdx: index('cases_firm_idx').on(t.firmId),
|
||||
clientIdx: index('cases_client_idx').on(t.clientId),
|
||||
statusIdx: index('cases_status_idx').on(t.firmId, t.status),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { pgTable, uuid, text, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
import { firms } from './firms';
|
||||
|
||||
export const clients = pgTable(
|
||||
'clients',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
firmId: uuid('firm_id')
|
||||
.notNull()
|
||||
.references(() => firms.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
email: text('email'),
|
||||
phone: text('phone'),
|
||||
address: text('address'),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
firmIdx: index('clients_firm_idx').on(t.firmId),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { pgTable, uuid, text, timestamp, integer, index } from 'drizzle-orm/pg-core';
|
||||
import { firms } from './firms';
|
||||
import { cases } from './cases';
|
||||
import { users } from './auth';
|
||||
|
||||
export const documents = pgTable(
|
||||
'documents',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
firmId: uuid('firm_id')
|
||||
.notNull()
|
||||
.references(() => firms.id, { onDelete: 'cascade' }),
|
||||
caseId: uuid('case_id').references(() => cases.id, { onDelete: 'cascade' }),
|
||||
uploadedBy: uuid('uploaded_by').references(() => users.id, { onDelete: 'set null' }),
|
||||
name: text('name').notNull(),
|
||||
storageKey: text('storage_key').notNull(),
|
||||
mimeType: text('mime_type').notNull(),
|
||||
sizeBytes: integer('size_bytes').notNull(),
|
||||
version: integer('version').notNull().default(1),
|
||||
parentId: uuid('parent_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
firmIdx: index('docs_firm_idx').on(t.firmId),
|
||||
caseIdx: index('docs_case_idx').on(t.caseId),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,16 @@
|
||||
import { pgTable, uuid, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const firms = pgTable('firms', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
plan: text('plan', { enum: ['starter', 'pro', 'lifetime'] })
|
||||
.notNull()
|
||||
.default('starter'),
|
||||
watermarkEnabled: boolean('watermark_enabled').notNull().default(true),
|
||||
storageBytesUsed: integer('storage_bytes_used').notNull().default(0),
|
||||
stripeCustomerId: text('stripe_customer_id'),
|
||||
stripeSubscriptionId: text('stripe_subscription_id'),
|
||||
trialEndsAt: timestamp('trial_ends_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './auth';
|
||||
export * from './firms';
|
||||
export * from './clients';
|
||||
export * from './cases';
|
||||
export * from './time';
|
||||
export * from './documents';
|
||||
export * from './invoices';
|
||||
export * from './misc';
|
||||
@@ -0,0 +1,53 @@
|
||||
import { pgTable, uuid, text, timestamp, numeric, integer, index } from 'drizzle-orm/pg-core';
|
||||
import { firms } from './firms';
|
||||
import { clients } from './clients';
|
||||
import { cases } from './cases';
|
||||
|
||||
export const invoices = pgTable(
|
||||
'invoices',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
firmId: uuid('firm_id')
|
||||
.notNull()
|
||||
.references(() => firms.id, { onDelete: 'cascade' }),
|
||||
clientId: uuid('client_id')
|
||||
.notNull()
|
||||
.references(() => clients.id, { onDelete: 'restrict' }),
|
||||
caseId: uuid('case_id').references(() => cases.id, { onDelete: 'set null' }),
|
||||
number: text('number').notNull(),
|
||||
status: text('status', { enum: ['draft', 'sent', 'paid', 'overdue', 'void'] })
|
||||
.notNull()
|
||||
.default('draft'),
|
||||
subtotal: numeric('subtotal', { precision: 12, scale: 2 }).notNull().default('0'),
|
||||
taxRate: numeric('tax_rate', { precision: 5, scale: 2 }).notNull().default('0'),
|
||||
total: numeric('total', { precision: 12, scale: 2 }).notNull().default('0'),
|
||||
notes: text('notes'),
|
||||
issuedAt: timestamp('issued_at', { withTimezone: true }),
|
||||
dueAt: timestamp('due_at', { withTimezone: true }),
|
||||
paidAt: timestamp('paid_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
firmIdx: index('invoices_firm_idx').on(t.firmId),
|
||||
statusIdx: index('invoices_status_idx').on(t.firmId, t.status),
|
||||
}),
|
||||
);
|
||||
|
||||
export const invoiceItems = pgTable(
|
||||
'invoice_items',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
invoiceId: uuid('invoice_id')
|
||||
.notNull()
|
||||
.references(() => invoices.id, { onDelete: 'cascade' }),
|
||||
description: text('description').notNull(),
|
||||
quantity: numeric('quantity', { precision: 10, scale: 2 }).notNull().default('1'),
|
||||
rate: numeric('rate', { precision: 10, scale: 2 }).notNull(),
|
||||
amount: numeric('amount', { precision: 12, scale: 2 }).notNull(),
|
||||
sortOrder: integer('sort_order').notNull().default(0),
|
||||
},
|
||||
(t) => ({
|
||||
invoiceIdx: index('invoice_items_invoice_idx').on(t.invoiceId),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { pgTable, uuid, text, timestamp, inet, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const contactMessages = pgTable('contact_messages', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
fullName: text('full_name').notNull(),
|
||||
email: text('email').notNull(),
|
||||
message: text('message').notNull(),
|
||||
ip: inet('ip'),
|
||||
resolvedAt: timestamp('resolved_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const toolUsage = pgTable(
|
||||
'tool_usage',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
tool: text('tool').notNull(),
|
||||
sessionId: text('session_id'),
|
||||
ip: inet('ip'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
toolIdx: index('tool_usage_tool_idx').on(t.tool, t.createdAt),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { pgTable, uuid, text, timestamp, numeric, integer, boolean, index } from 'drizzle-orm/pg-core';
|
||||
import { firms } from './firms';
|
||||
import { cases } from './cases';
|
||||
import { users } from './auth';
|
||||
|
||||
export const timeEntries = pgTable(
|
||||
'time_entries',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
firmId: uuid('firm_id')
|
||||
.notNull()
|
||||
.references(() => firms.id, { onDelete: 'cascade' }),
|
||||
caseId: uuid('case_id')
|
||||
.notNull()
|
||||
.references(() => cases.id, { onDelete: 'cascade' }),
|
||||
userId: uuid('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'restrict' }),
|
||||
description: text('description').notNull(),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }).notNull(),
|
||||
endedAt: timestamp('ended_at', { withTimezone: true }),
|
||||
minutes: integer('minutes').notNull().default(0),
|
||||
rate: numeric('rate', { precision: 10, scale: 2 }).notNull(),
|
||||
billable: boolean('billable').notNull().default(true),
|
||||
invoiceItemId: uuid('invoice_item_id'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
firmIdx: index('time_firm_idx').on(t.firmId),
|
||||
caseIdx: index('time_case_idx').on(t.caseId),
|
||||
userIdx: index('time_user_idx').on(t.userId, t.startedAt),
|
||||
unbilledIdx: index('time_unbilled_idx').on(t.firmId, t.invoiceItemId),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user