mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-30 20:54:49 +02:00
Merge pull request #77 from open-reception/60-autogenerate-main-database-for-initial-setup
60 - Generate and migrate central database on startup if needed. Adde…
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator";
|
||||
import postgres from "postgres";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
import { UniversalLogger } from "$lib/logger";
|
||||
import { ValidationError } from "$lib/server/utils/errors";
|
||||
import { env } from "$env/dynamic/private";
|
||||
|
||||
const logger = new UniversalLogger().setContext("CentralDatabaseMigrationService");
|
||||
|
||||
export interface CentralDatabaseConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class CentralDatabaseMigrationService {
|
||||
/**
|
||||
* Create a new database
|
||||
*/
|
||||
private static async createCentralDatabase(config: CentralDatabaseConfig): Promise<void> {
|
||||
logger.info("Creating central database", { database: config.database });
|
||||
|
||||
// Connect to postgres database to create the new central database
|
||||
const adminConnectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/postgres`;
|
||||
const adminClient = postgres(adminConnectionString);
|
||||
|
||||
try {
|
||||
// Create the database
|
||||
await adminClient.unsafe(`CREATE DATABASE "${config.database}"`);
|
||||
logger.info("Central database created successfully", { database: config.database });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
logger.warn("Central database already exists", { database: config.database });
|
||||
} else {
|
||||
logger.error("Failed to create central database", {
|
||||
database: config.database,
|
||||
error: String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
await adminClient.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize central database with schema
|
||||
*/
|
||||
private static async initializeCentralSchema(config: CentralDatabaseConfig): Promise<void> {
|
||||
logger.info("Initializing central database schema", { database: config.database });
|
||||
|
||||
const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`;
|
||||
const client = postgres(connectionString);
|
||||
|
||||
try {
|
||||
// Create drizzle instance for the central database
|
||||
const db = drizzle(client);
|
||||
|
||||
// Get the path to central migrations
|
||||
const migrationsPath = join(process.cwd(), "migrations");
|
||||
|
||||
// Apply migrations
|
||||
await migrate(db, { migrationsFolder: migrationsPath });
|
||||
|
||||
logger.info("Central database schema initialized successfully", {
|
||||
database: config.database,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize central database schema", {
|
||||
database: config.database,
|
||||
error: String(error),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if central database exists
|
||||
*/
|
||||
private static async centralDatabaseExists(config: CentralDatabaseConfig): Promise<boolean> {
|
||||
const adminConnectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/postgres`;
|
||||
const adminClient = postgres(adminConnectionString);
|
||||
|
||||
try {
|
||||
const result = await adminClient`
|
||||
SELECT 1 FROM pg_database WHERE datname = ${config.database}
|
||||
`;
|
||||
return result.length > 0;
|
||||
} catch (error) {
|
||||
logger.error("Failed to check central database existence", {
|
||||
database: config.database,
|
||||
error: String(error),
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
await adminClient.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current schema version for the central database
|
||||
*/
|
||||
private static async getCentralSchemaVersion(
|
||||
config: CentralDatabaseConfig,
|
||||
): Promise<string | null> {
|
||||
const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`;
|
||||
const client = postgres(connectionString);
|
||||
|
||||
try {
|
||||
// Check if migrations table exists
|
||||
const tableExists = await client`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = '__drizzle_migrations'
|
||||
);
|
||||
`;
|
||||
|
||||
if (!tableExists[0].exists) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the latest migration
|
||||
const result = await client`
|
||||
SELECT hash FROM __drizzle_migrations
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
return result.length > 0 ? result[0].hash : null;
|
||||
} catch (error) {
|
||||
logger.error("Failed to get central schema version", {
|
||||
database: config.database,
|
||||
error: String(error),
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest available migration hash for central database
|
||||
*/
|
||||
private static getLatestCentralMigrationHash(): string | null {
|
||||
try {
|
||||
const migrationsPath = join(process.cwd(), "migrations");
|
||||
const metaPath = join(migrationsPath, "meta", "_journal.json");
|
||||
|
||||
if (!existsSync(metaPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const journal = JSON.parse(readFileSync(metaPath, "utf-8"));
|
||||
const entries = journal.entries || [];
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the latest entry
|
||||
const latestEntry = entries[entries.length - 1];
|
||||
return latestEntry.hash || null;
|
||||
} catch (error) {
|
||||
logger.error("Failed to get latest central migration hash", { error: String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if central database needs migration
|
||||
*/
|
||||
private static async centralNeedsMigration(config: CentralDatabaseConfig): Promise<boolean> {
|
||||
const currentVersion = await this.getCentralSchemaVersion(config);
|
||||
const latestVersion = this.getLatestCentralMigrationHash();
|
||||
|
||||
if (!latestVersion) {
|
||||
return false; // No migrations available
|
||||
}
|
||||
|
||||
if (!currentVersion) {
|
||||
return true; // Database has no schema yet
|
||||
}
|
||||
|
||||
return currentVersion !== latestVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply pending migrations to the central database
|
||||
*/
|
||||
private static async migrateCentralDatabase(config: CentralDatabaseConfig): Promise<void> {
|
||||
logger.info("Migrating central database", { database: config.database });
|
||||
|
||||
const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`;
|
||||
const client = postgres(connectionString);
|
||||
|
||||
try {
|
||||
const db = drizzle(client);
|
||||
const migrationsPath = join(process.cwd(), "migrations");
|
||||
|
||||
await migrate(db, { migrationsFolder: migrationsPath });
|
||||
|
||||
logger.info("Central database migrated successfully", { database: config.database });
|
||||
} catch (error) {
|
||||
logger.error("Failed to migrate central database", {
|
||||
database: config.database,
|
||||
error: String(error),
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create central database configuration from DATABASE_URL environment variable
|
||||
*/
|
||||
private static parseCentralDatabaseUrl(): CentralDatabaseConfig {
|
||||
if (!env.DATABASE_URL) {
|
||||
throw new ValidationError("DATABASE_URL environment variable is not set");
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(env.DATABASE_URL);
|
||||
|
||||
return {
|
||||
host: url.hostname,
|
||||
port: parseInt(url.port) || 5432,
|
||||
database: url.pathname.slice(1), // Remove leading slash
|
||||
username: url.username,
|
||||
password: url.password,
|
||||
};
|
||||
} catch {
|
||||
throw new ValidationError(`Invalid DATABASE_URL: ${env.DATABASE_URL}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a complete central database with schema
|
||||
*/
|
||||
private static async createAndInitializeCentralDatabase(): Promise<void> {
|
||||
const config = this.parseCentralDatabaseUrl();
|
||||
|
||||
// Create the database
|
||||
await this.createCentralDatabase(config);
|
||||
|
||||
// Initialize the schema
|
||||
await this.initializeCentralSchema(config);
|
||||
|
||||
logger.info("Central database created and initialized", {
|
||||
database: config.database,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure central database is up to date
|
||||
*/
|
||||
static async ensureCentralDatabaseUpToDate(): Promise<void> {
|
||||
const config = this.parseCentralDatabaseUrl();
|
||||
|
||||
logger.info("Checking central database status", { database: config.database });
|
||||
|
||||
// Check if database exists
|
||||
const exists = await this.centralDatabaseExists(config);
|
||||
if (!exists) {
|
||||
logger.info("Central database does not exist, creating...", { database: config.database });
|
||||
await this.createAndInitializeCentralDatabase();
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Central database exists, checking for pending migrations", {
|
||||
database: config.database,
|
||||
});
|
||||
|
||||
// Check if migration is needed
|
||||
const needsMigration = await this.centralNeedsMigration(config);
|
||||
if (needsMigration) {
|
||||
logger.info("Central database needs migration, applying...", { database: config.database });
|
||||
await this.migrateCentralDatabase(config);
|
||||
} else {
|
||||
logger.info("Central database is up to date", { database: config.database });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { centralDb } from "../db";
|
||||
import { tenant } from "../db/central-schema";
|
||||
import { TenantMigrationService } from "./tenant-migration-service";
|
||||
import { CentralDatabaseMigrationService } from "./central-database-migration-service";
|
||||
import { UniversalLogger } from "$lib/logger";
|
||||
|
||||
const logger = new UniversalLogger().setContext("StartupService");
|
||||
@@ -21,7 +22,10 @@ export class StartupService {
|
||||
logger.info("Starting application initialization");
|
||||
|
||||
try {
|
||||
// Check and migrate all tenant databases
|
||||
// First ensure central database is up to date
|
||||
await this.ensureCentralDatabase();
|
||||
|
||||
// Then check and migrate all tenant databases
|
||||
await this.migrateTenantDatabases();
|
||||
|
||||
this.initialized = true;
|
||||
@@ -32,6 +36,21 @@ export class StartupService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure central database exists and is up to date
|
||||
*/
|
||||
private static async ensureCentralDatabase(): Promise<void> {
|
||||
logger.info("Ensuring central database is up to date");
|
||||
|
||||
try {
|
||||
await CentralDatabaseMigrationService.ensureCentralDatabaseUpToDate();
|
||||
logger.info("Central database is ready");
|
||||
} catch (error) {
|
||||
logger.error("Failed to setup central database", { error: String(error) });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check and migrate all tenant databases
|
||||
*/
|
||||
|
||||
@@ -13,12 +13,16 @@ import { sendTenantAdminInviteEmail } from "../email/email-service";
|
||||
|
||||
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
|
||||
|
||||
const tentantCreationSchema = z.object({
|
||||
shortName: z.string().min(4).max(15),
|
||||
const tenantCreationSchema = z.object({
|
||||
shortName: z
|
||||
.string()
|
||||
.min(4)
|
||||
.max(15)
|
||||
.regex(/^[a-z][a-z-]*[a-z]$/),
|
||||
inviteAdmin: z.email().optional(),
|
||||
});
|
||||
|
||||
export type TenantCreationRequest = z.infer<typeof tentantCreationSchema>;
|
||||
export type TenantCreationRequest = z.infer<typeof tenantCreationSchema>;
|
||||
|
||||
export interface TenantConfiguration extends Record<string, string | number | boolean> {
|
||||
brandColor: string;
|
||||
@@ -60,7 +64,7 @@ export class TenantAdminService {
|
||||
static async createTenant(request: TenantCreationRequest) {
|
||||
const log = logger.setContext("TenantAdminService");
|
||||
|
||||
const validation = tentantCreationSchema.safeParse(request);
|
||||
const validation = tenantCreationSchema.safeParse(request);
|
||||
|
||||
if (!validation.success) throw new ValidationError("Invalid tenant creation request");
|
||||
|
||||
@@ -237,6 +241,15 @@ export class TenantAdminService {
|
||||
updateFields: Object.keys(updateData),
|
||||
});
|
||||
|
||||
if (updateData.shortName) {
|
||||
const shortNameValidation = tenantCreationSchema.shape.shortName.safeParse(
|
||||
updateData.shortName,
|
||||
);
|
||||
if (!shortNameValidation.success) {
|
||||
throw new ValidationError("Invalid shortName format");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await centralDb
|
||||
.update(centralSchema.tenant)
|
||||
|
||||
@@ -257,19 +257,27 @@ export class TenantMigrationService {
|
||||
static async ensureTenantDatabaseUpToDate(databaseUrl: string): Promise<void> {
|
||||
const config = this.parseDatabaseUrl(databaseUrl);
|
||||
|
||||
logger.info("Checking tenant database status", { database: config.database });
|
||||
|
||||
// Check if database exists
|
||||
const exists = await this.tenantDatabaseExists(config);
|
||||
if (!exists) {
|
||||
logger.info("Tenant database does not exist, creating...", { database: config.database });
|
||||
await this.createAndInitializeTenantDatabase(databaseUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info("Tenant database exists, checking for pending migrations", {
|
||||
database: config.database,
|
||||
});
|
||||
|
||||
// Check if migration is needed
|
||||
const needsMigration = await this.tenantNeedsMigration(config);
|
||||
if (needsMigration) {
|
||||
logger.info("Tenant database needs migration, applying...", { database: config.database });
|
||||
await this.migrateTenantDatabase(config);
|
||||
} else {
|
||||
logger.info("Tenant database is up to date", { database: config.database });
|
||||
}
|
||||
|
||||
logger.debug("Tenant database is up to date", { database: config.database });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user