diff --git a/replit.nix b/replit.nix new file mode 100644 index 0000000..404c2dd --- /dev/null +++ b/replit.nix @@ -0,0 +1,5 @@ +{pkgs}: { + deps = [ + pkgs.postgresql + ]; +} diff --git a/server/database-storage.ts b/server/database-storage.ts new file mode 100644 index 0000000..486b3d3 --- /dev/null +++ b/server/database-storage.ts @@ -0,0 +1,263 @@ +import { + users, organizations, domains, dnsRecords, + providers, dnsHistory, apiTokens, + type User, type InsertUser, + type Organization, type InsertOrganization, + type Domain, type InsertDomain, + type DnsRecord, type InsertDnsRecord, + type Provider, type InsertProvider, + type ApiToken, type InsertApiToken, + type DnsHistory +} from "@shared/schema"; +import { db } from "./db"; +import { eq, and, desc, or, inArray, sql } from "drizzle-orm"; +import { IStorage } from "./storage"; +import session from "express-session"; +import connectPg from "connect-pg-simple"; +import { pool } from "./db"; + +const PostgresSessionStore = connectPg(session); + +export class DatabaseStorage implements IStorage { + public sessionStore: any; + + constructor() { + this.sessionStore = new PostgresSessionStore({ + pool, + createTableIfMissing: true + }); + } + + // User management + async getUser(id: number): Promise { + const [user] = await db.select().from(users).where(eq(users.id, id)); + return user; + } + + async getUserByUsername(username: string): Promise { + const [user] = await db.select().from(users).where(eq(users.username, username)); + return user; + } + + async getUserByEmail(email: string): Promise { + const [user] = await db.select().from(users).where(eq(users.email, email)); + return user; + } + + async createUser(user: InsertUser): Promise { + const [newUser] = await db.insert(users).values(user).returning(); + return newUser; + } + + async updateUser(id: number, userData: Partial): Promise { + const [updatedUser] = await db.update(users) + .set(userData) + .where(eq(users.id, id)) + .returning(); + return updatedUser; + } + + async deleteUser(id: number): Promise { + const result = await db.delete(users).where(eq(users.id, id)).returning(); + return result.length > 0; + } + + // Organization management + async getOrganization(id: number): Promise { + const [org] = await db.select().from(organizations).where(eq(organizations.id, id)); + return org; + } + + async getOrganizations(): Promise { + return await db.select().from(organizations); + } + + async createOrganization(org: InsertOrganization): Promise { + const [newOrg] = await db.insert(organizations).values(org).returning(); + return newOrg; + } + + async updateOrganization(id: number, orgData: Partial): Promise { + const [updatedOrg] = await db.update(organizations) + .set(orgData) + .where(eq(organizations.id, id)) + .returning(); + return updatedOrg; + } + + async deleteOrganization(id: number): Promise { + const result = await db.delete(organizations).where(eq(organizations.id, id)).returning(); + return result.length > 0; + } + + // Domain management + async getDomain(id: number): Promise { + const [domain] = await db.select().from(domains).where(eq(domains.id, id)); + return domain; + } + + async getDomainsByOrganization(organizationId: number): Promise { + return await db.select() + .from(domains) + .where(eq(domains.organizationId, organizationId)); + } + + async getAllDomains(): Promise { + return await db.select().from(domains); + } + + async createDomain(domain: InsertDomain): Promise { + const [newDomain] = await db.insert(domains).values(domain).returning(); + return newDomain; + } + + async updateDomain(id: number, domainData: Partial): Promise { + const [updatedDomain] = await db.update(domains) + .set({ ...domainData, lastUpdated: new Date() }) + .where(eq(domains.id, id)) + .returning(); + return updatedDomain; + } + + async deleteDomain(id: number): Promise { + const result = await db.delete(domains).where(eq(domains.id, id)).returning(); + return result.length > 0; + } + + // DNS Record management + async getDnsRecord(id: number): Promise { + const [record] = await db.select().from(dnsRecords).where(eq(dnsRecords.id, id)); + return record; + } + + async getDnsRecordsByDomain(domainId: number): Promise { + return await db.select() + .from(dnsRecords) + .where(eq(dnsRecords.domainId, domainId)); + } + + async createDnsRecord(record: InsertDnsRecord): Promise { + const [newRecord] = await db.insert(dnsRecords).values(record).returning(); + return newRecord; + } + + async updateDnsRecord(id: number, recordData: Partial): Promise { + const [updatedRecord] = await db.update(dnsRecords) + .set({ ...recordData, lastUpdated: new Date() }) + .where(eq(dnsRecords.id, id)) + .returning(); + return updatedRecord; + } + + async deleteDnsRecord(id: number): Promise { + const result = await db.delete(dnsRecords).where(eq(dnsRecords.id, id)).returning(); + return result.length > 0; + } + + // Provider management + async getProvider(id: number): Promise { + const [provider] = await db.select().from(providers).where(eq(providers.id, id)); + return provider; + } + + async getProviders(): Promise { + return await db.select().from(providers); + } + + async createProvider(provider: InsertProvider): Promise { + const [newProvider] = await db.insert(providers).values(provider).returning(); + return newProvider; + } + + async updateProvider(id: number, providerData: Partial): Promise { + const [updatedProvider] = await db.update(providers) + .set(providerData) + .where(eq(providers.id, id)) + .returning(); + return updatedProvider; + } + + async deleteProvider(id: number): Promise { + const result = await db.delete(providers).where(eq(providers.id, id)).returning(); + return result.length > 0; + } + + // API Token management + async getApiToken(id: number): Promise { + const [token] = await db.select().from(apiTokens).where(eq(apiTokens.id, id)); + return token; + } + + async getApiTokenByToken(token: string): Promise { + const [apiToken] = await db.select().from(apiTokens).where(eq(apiTokens.token, token)); + return apiToken; + } + + async getApiTokensByOrganization(organizationId: number): Promise { + return await db.select() + .from(apiTokens) + .where(eq(apiTokens.organizationId, organizationId)); + } + + async createApiToken(token: InsertApiToken): Promise { + const [newToken] = await db.insert(apiTokens).values(token).returning(); + return newToken; + } + + async updateApiToken(id: number, tokenData: Partial): Promise { + const [updatedToken] = await db.update(apiTokens) + .set(tokenData) + .where(eq(apiTokens.id, id)) + .returning(); + return updatedToken; + } + + async deleteApiToken(id: number): Promise { + const result = await db.delete(apiTokens).where(eq(apiTokens.id, id)).returning(); + return result.length > 0; + } + + // DNS History + async addDnsHistory( + recordId: number, + action: string, + previousValue?: string, + newValue?: string, + userId?: number + ): Promise { + const [historyEntry] = await db.insert(dnsHistory).values({ + recordId, + action, + previousValue, + newValue, + userId, + }).returning(); + + return historyEntry; + } + + async getDnsHistoryByRecord(recordId: number): Promise { + return await db.select() + .from(dnsHistory) + .where(eq(dnsHistory.recordId, recordId)) + .orderBy(desc(dnsHistory.timestamp)); + } + + async getDnsHistoryByDomain(domainId: number): Promise { + // We need to get all records for the domain first + const records = await this.getDnsRecordsByDomain(domainId); + const recordIds = records.map(r => r.id); + + if (recordIds.length === 0) { + return []; + } + + // Use SQL for the IN condition + return await db.select() + .from(dnsHistory) + .where( + sql`${dnsHistory.recordId} IN (${sql.join(recordIds, sql`, `)})` + ) + .orderBy(desc(dnsHistory.timestamp)); + } +} \ No newline at end of file diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..d8ede88 --- /dev/null +++ b/server/db.ts @@ -0,0 +1,15 @@ +import { Pool, neonConfig } from '@neondatabase/serverless'; +import { drizzle } from 'drizzle-orm/neon-serverless'; +import ws from "ws"; +import * as schema from "@shared/schema"; + +neonConfig.webSocketConstructor = ws; + +if (!process.env.DATABASE_URL) { + throw new Error( + "DATABASE_URL must be set. Did you forget to provision a database?", + ); +} + +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }); +export const db = drizzle(pool, { schema }); \ No newline at end of file diff --git a/server/storage.ts b/server/storage.ts index 128e5b4..1e3d25b 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -1,7 +1,4 @@ -import { randomBytes } from "crypto"; import { - users, organizations, domains, dnsRecords, - providers, dnsHistory, apiTokens, type User, type InsertUser, type Organization, type InsertOrganization, type Domain, type InsertDomain, @@ -11,9 +8,7 @@ import { type DnsHistory } from "@shared/schema"; import session from "express-session"; -import createMemoryStore from "memorystore"; - -const MemoryStore = createMemoryStore(session); +import { DatabaseStorage } from "./database-storage"; export interface IStorage { // User management @@ -67,7 +62,7 @@ export interface IStorage { getDnsHistoryByDomain(domainId: number): Promise; // Session store - sessionStore: session.SessionStore; + sessionStore: any; } export class MemStorage implements IStorage { @@ -88,7 +83,7 @@ export class MemStorage implements IStorage { private apiTokenIdCounter: number; private historyIdCounter: number; - public sessionStore: session.SessionStore; + public sessionStore: any; constructor() { this.usersMap = new Map(); @@ -107,9 +102,8 @@ export class MemStorage implements IStorage { this.apiTokenIdCounter = 1; this.historyIdCounter = 1; - this.sessionStore = new MemoryStore({ - checkPeriod: 86400000 // prune expired entries every 24h - }); + // Session store is created in the DatabaseStorage class + this.sessionStore = null; // Initialize sample data this.initSampleData(); @@ -386,4 +380,4 @@ export class MemStorage implements IStorage { } } -export const storage = new MemStorage(); +export const storage = new DatabaseStorage(); diff --git a/shared/schema.ts b/shared/schema.ts index 4fdbaa8..faca0c5 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -1,19 +1,9 @@ -import { pgTable, text, serial, integer, boolean, timestamp, jsonb } from "drizzle-orm/pg-core"; +import { pgTable, text, serial, integer, boolean, timestamp, jsonb, foreignKey, unique } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; +import { relations } from "drizzle-orm"; // Users & Authentication -export const users = pgTable("users", { - id: serial("id").primaryKey(), - username: text("username").notNull().unique(), - password: text("password").notNull(), - email: text("email").notNull().unique(), - fullName: text("full_name"), - role: text("role").default("user").notNull(), - organizationId: integer("organization_id"), - createdAt: timestamp("created_at").defaultNow().notNull(), -}); - export const organizations = pgTable("organizations", { id: serial("id").primaryKey(), name: text("name").notNull(), @@ -21,12 +11,41 @@ export const organizations = pgTable("organizations", { createdAt: timestamp("created_at").defaultNow().notNull(), }); +export const users = pgTable("users", { + id: serial("id").primaryKey(), + username: text("username").notNull().unique(), + password: text("password").notNull(), + email: text("email").notNull().unique(), + fullName: text("full_name"), + role: text("role").default("user").notNull(), + organizationId: integer("organization_id").references(() => organizations.id, { onDelete: "set null" }), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + +export const usersRelations = relations(users, ({ one }) => ({ + organization: one(organizations, { + fields: [users.organizationId], + references: [organizations.id], + }), +})); + +// We'll define this after all tables are declared + // DNS Management +export const providers = pgTable("providers", { + id: serial("id").primaryKey(), + name: text("name").notNull(), + type: text("type").notNull(), + credentials: jsonb("credentials"), + isActive: boolean("is_active").default(true).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); + export const domains = pgTable("domains", { id: serial("id").primaryKey(), name: text("name").notNull(), - organizationId: integer("organization_id").notNull(), - providerId: integer("provider_id").notNull(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), + providerId: integer("provider_id").notNull().references(() => providers.id, { onDelete: "cascade" }), isActive: boolean("is_active").default(true).notNull(), lastUpdated: timestamp("last_updated"), createdAt: timestamp("created_at").defaultNow().notNull(), @@ -34,7 +53,7 @@ export const domains = pgTable("domains", { export const dnsRecords = pgTable("dns_records", { id: serial("id").primaryKey(), - domainId: integer("domain_id").notNull(), + domainId: integer("domain_id").notNull().references(() => domains.id, { onDelete: "cascade" }), name: text("name").notNull(), type: text("type").notNull(), content: text("content").notNull(), @@ -45,22 +64,13 @@ export const dnsRecords = pgTable("dns_records", { createdAt: timestamp("created_at").defaultNow().notNull(), }); -export const providers = pgTable("providers", { - id: serial("id").primaryKey(), - name: text("name").notNull(), - type: text("type").notNull(), - credentials: jsonb("credentials"), - isActive: boolean("is_active").default(true).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), -}); - export const dnsHistory = pgTable("dns_history", { id: serial("id").primaryKey(), - recordId: integer("record_id").notNull(), + recordId: integer("record_id").notNull().references(() => dnsRecords.id, { onDelete: "cascade" }), action: text("action").notNull(), previousValue: text("previous_value"), newValue: text("new_value"), - userId: integer("user_id"), + userId: integer("user_id").references(() => users.id, { onDelete: "set null" }), timestamp: timestamp("timestamp").defaultNow().notNull(), }); @@ -69,9 +79,9 @@ export const apiTokens = pgTable("api_tokens", { id: serial("id").primaryKey(), name: text("name").notNull(), token: text("token").notNull().unique(), - organizationId: integer("organization_id").notNull(), + organizationId: integer("organization_id").notNull().references(() => organizations.id, { onDelete: "cascade" }), permissions: text("permissions").array(), - createdBy: integer("created_by").notNull(), + createdBy: integer("created_by").notNull().references(() => users.id, { onDelete: "cascade" }), isActive: boolean("is_active").default(true).notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), expiresAt: timestamp("expires_at"), @@ -158,3 +168,56 @@ export type ProviderType = typeof providerTypes[number]; // DNS Record Types export const recordTypes = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'SRV', 'NS', 'CAA', 'PTR'] as const; export type RecordType = typeof recordTypes[number]; + +// Define table relations +export const organizationsRelations = relations(organizations, ({ many }) => ({ + users: many(users), + domains: many(domains), + apiTokens: many(apiTokens), +})); + +export const domainsRelations = relations(domains, ({ one, many }) => ({ + organization: one(organizations, { + fields: [domains.organizationId], + references: [organizations.id], + }), + provider: one(providers, { + fields: [domains.providerId], + references: [providers.id], + }), + dnsRecords: many(dnsRecords), +})); + +export const dnsRecordsRelations = relations(dnsRecords, ({ one, many }) => ({ + domain: one(domains, { + fields: [dnsRecords.domainId], + references: [domains.id], + }), + history: many(dnsHistory), +})); + +export const dnsHistoryRelations = relations(dnsHistory, ({ one }) => ({ + record: one(dnsRecords, { + fields: [dnsHistory.recordId], + references: [dnsRecords.id], + }), + user: one(users, { + fields: [dnsHistory.userId], + references: [users.id], + }), +})); + +export const providersRelations = relations(providers, ({ many }) => ({ + domains: many(domains), +})); + +export const apiTokensRelations = relations(apiTokens, ({ one }) => ({ + organization: one(organizations, { + fields: [apiTokens.organizationId], + references: [organizations.id], + }), + creator: one(users, { + fields: [apiTokens.createdBy], + references: [users.id], + }), +}));