From d458a635aabbe23a1c8ee52ee37119b68c1782fc Mon Sep 17 00:00:00 2001 From: Adegbenga Date: Thu, 4 Jan 2024 20:19:09 +0000 Subject: [PATCH] createTenant module alongside test setup --- .DS_Store | Bin 8196 -> 0 bytes .dockerignore | 1 - .env | 5 + .github/workflows/coverage.yml | 10 +- .gitignore | 5 +- Dockerfile | 60 +------- README.md | 19 ++- __tests__/.env | 5 + __tests__/index.test.ts | 7 - __tests__/jest.setup.ts | 6 + __tests__/jest.teardown.ts | 5 + __tests__/tenant.test.ts | 109 ++++++++++++++ compose.yaml | 75 +++++++--- jest.config.ts | 178 +---------------------- knexfile.ts | 8 +- package.json | 14 +- pnpm-lock.yaml | 8 + prisma/schema.prisma | 12 +- src/helpers/index.ts | 27 +++- src/index.ts | 44 ++++++ src/migrations/20240103173520_init-db.ts | 19 --- src/modules/cli.ts | 0 src/modules/tenant.ts | 166 +++++++++++++++++++++ src/modules/types.ts | 11 ++ tsconfig.json | 5 +- 25 files changed, 496 insertions(+), 303 deletions(-) delete mode 100644 .DS_Store create mode 100644 .env create mode 100644 __tests__/.env delete mode 100644 __tests__/index.test.ts create mode 100644 __tests__/jest.setup.ts create mode 100644 __tests__/jest.teardown.ts create mode 100644 __tests__/tenant.test.ts delete mode 100644 src/migrations/20240103173520_init-db.ts create mode 100644 src/modules/cli.ts create mode 100644 src/modules/tenant.ts create mode 100644 src/modules/types.ts diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 11af70c6cb62fa98e65304f10eb2cefb8be0fc58..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHMO=uHA6n>k=HZdqUD7N5XK~Iuu`ll@-#2D||_=ETpv)Rxr-Ry?!CP7LemnzhY zAbP1Mz39P{6!hdlJP0D5s%JsOqvBQY%}<2V#5B9=DzT{SGeMnWI6i^B%1(X6x0j0pdq5$sM zB*g~o`^>9yrGQdkTPi@-2OTeBGu*bBrAr59^ay}9iq$k>IY|m=b#61k z%bIHZh@r9OSg&(jhTAr?tT|}>@S*XMHGV?T=;+wLPT`+K z1-^8CPt2@~zN(wc8MbR+UF_b=o2xSqUZ0fx=BEByMff%_b_ScmNM~HAKm!bEbkM%_ zBSa3~=d)kltx*nveyVPd$zYVkhb=n8$cHfTU=ePZUdc9G*Cy+Yy)Y zLsIHPq-Zq9DtFmLB#cZjgTp`q3D&^Ds|w>t(+3yoP?i}H;2@;PE&l4(zH#;t2lOkl z+!`(Or7XyUX(G=O@-%}ywQ0lz5F)?UkNxKR-UAl;5qS+p$$WG|C=Lbu^Px<&^T?gi z@@hP+pBpvX^89pqLyHVWcMOkUN{n31SKLT3%`-vm>|*!b{5FyzN`E$v}`kqdtj-HB_Zh4Dn9%Eyw6>!&Aki2Otj zp*WGxi#yHK*N1e&8G!9^+X@@Pa|pGmLrG=^ACMXMh+Gb?bbqN`Z*TN|Z^(GGW6bc| zKRN%O9{l~Ef>fpyPzwB?3TV+>K9|M0b>1+7( @@ -38,14 +38,21 @@ This application relies heavily on sequelizejs for its database connections. Dia ```sh $ npm i node-multi-tenant +$ yarn i node-multi-tenant +$ pnpm i node-multi-tenant ``` **or** ```sh $ npm install --save https://github.com/deye9/node-multi-tenant +$ yarn install --save https://github.com/deye9/node-multi-tenant +$ pnpm install --save https://github.com/deye9/node-multi-tenant ``` +## Flow +1. Tenant db_name is optional. If supplied the value given will be used to create the database name else it will be assigned a random *16* string value. + ## Before you start Drop all migrations for the tenants in the tenants folder. diff --git a/__tests__/.env b/__tests__/.env new file mode 100644 index 0000000..1959877 --- /dev/null +++ b/__tests__/.env @@ -0,0 +1,5 @@ +NODE_ENV=test +MANAGE_TENANTS=true +POSTGRES_PASSWORD=postgres +POSTGRES_DB=multiTenant_test +DATABASE_URL=postgres://postgres:postgres@testdb:5432/${POSTGRES_DB} \ No newline at end of file diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts deleted file mode 100644 index ca1fb8e..0000000 --- a/__tests__/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {hello} from '../src/index'; - -describe('test hello', () => { - it('should return hello world', () => { - expect(hello()).toBe('Hello world!'); - }); - }); \ No newline at end of file diff --git a/__tests__/jest.setup.ts b/__tests__/jest.setup.ts new file mode 100644 index 0000000..518d072 --- /dev/null +++ b/__tests__/jest.setup.ts @@ -0,0 +1,6 @@ +import { executeQuery } from '../src/helpers'; + +export default async () => { + // Drop test database if it exists + await executeQuery('DROP DATABASE IF EXISTS "multiTenant_test";'); +}; diff --git a/__tests__/jest.teardown.ts b/__tests__/jest.teardown.ts new file mode 100644 index 0000000..3f1e4bd --- /dev/null +++ b/__tests__/jest.teardown.ts @@ -0,0 +1,5 @@ +import { executeQuery } from '../src/helpers'; + +export default async () => { + await executeQuery('DROP DATABASE "multiTenant_test";'); +}; diff --git a/__tests__/tenant.test.ts b/__tests__/tenant.test.ts new file mode 100644 index 0000000..dee3efb --- /dev/null +++ b/__tests__/tenant.test.ts @@ -0,0 +1,109 @@ +import { genId } from '../src/helpers'; +import { faker } from '@faker-js/faker'; +import { ITenant } from '../src/modules/types'; +import { tenants } from '../src/modules/tenant'; + +let tenantID: string; +let newTenant: ITenant; + +describe('tenants module', () => { + describe('tenant without a set db_name parameter', () => { + beforeEach(() => { + // Create a new tenant + newTenant = { + createdAt: new Date(), + updatedAt: new Date(), + fqdn: faker.internet.domainName(), + redirect_to: faker.string.nanoid(), + force_https: faker.datatype.boolean(), + }; + }); + + afterEach(() => { + // Dispose of the tenant Object + newTenant = { + fqdn: '', + redirect_to: '', + force_https: false, + createdAt: new Date(), + updatedAt: new Date(), + }; + }); + + it('should catch all unhandled errors', async () => { + await expect(tenants.createTenant(newTenant)).rejects.toThrow( + 'Tenant object is empty', + ); + }); + + it('should create a tenant and return the tenant ID', async () => { + tenantID = await tenants.createTenant(newTenant); + + expect(tenantID).not.toBe(''); + expect(tenantID).toBeDefined(); + expect(tenantID).not.toBeNull(); + expect(tenantID).not.toBeUndefined(); + }); + + it('should throw an error if the tenant already exists', async () => { + await expect(tenants.createTenant(newTenant)).rejects.toThrow( + 'Tenant already exists', + ); + }); + + it('should throw an error if the tenant object is empty', async () => { + await expect(tenants.createTenant({})).rejects.toThrow( + 'Tenant object is empty', + ); + }); + + // Update Tenant Section + it('should update a tenant', async () => { + newTenant.db_name = genId(); + newTenant.fqdn = faker.internet.domainName(); + const result = await tenants.updateTenant(tenantID, newTenant); + + expect(result).not.toBe(''); + expect(result).toBeDefined(); + expect(result).not.toBeNull(); + expect(result).not.toBeUndefined(); + }); + + it('should throw an error if the tenant does not exist', async () => { + await expect(tenants.updateTenant('null', newTenant)).rejects.toThrow( + 'Tenant does not exist', + ); + }); + + it('should throw an error if the tenant ID is empty', async () => { + await expect(tenants.updateTenant('', {})).rejects.toThrow( + 'Tenant ID is empty', + ); + }); + + it('should throw an error if the tenant object is empty', async () => { + await expect(tenants.createTenant(null, {})).rejects.toThrow( + 'Tenant object is empty', + ); + }); + + // Delete Tenant Section + it('should delete a tenant', async () => { + const result = await tenants.deleteTenant(tenantID); + + expect(result).toBe(true); + }); + + it('should throw an error if the tenant ID is empty', async () => { + await expect(tenants.deleteTenant('')).rejects.toThrow( + 'Tenant ID is empty', + ); + }); + + it('should throw an error if the tenant does not exist', async () => { + await expect(tenants.deleteTenant('null')).rejects.toThrow( + 'Tenant does not exist', + ); + }); + }); +}); diff --git a/compose.yaml b/compose.yaml index a50ec53..7a4cdeb 100644 --- a/compose.yaml +++ b/compose.yaml @@ -2,9 +2,8 @@ services: backend: build: context: . - environment: - NODE_ENV: production - DATABASE_URL: postgres://postgres:postgres@pg:5432/multiTenant_dev + env_file: + - .env ports: - 1981:5000 - 9229:9229 # for debugging @@ -14,45 +13,79 @@ services: - /usr/src/app/.pnpm-store command: pnpm docker:start depends_on: - pg: + postgres: condition: service_healthy - + profiles: ["main"] + pgadmin: image: dpage/pgadmin4 restart: always container_name: pgadmin4_container environment: - PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org} - PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} PGADMIN_CONFIG_SERVER_MODE: 'False' + PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin} + PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org} volumes: - - ./db/pgadmin:/var/lib/pgadmin + - pgadmin:/var/lib/pgadmin ports: - - "${PGADMIN_LISTEN_PORT:-5050}:80" - command: pnpm docker:pgadmin + - '${PGADMIN_LISTEN_PORT:-5050}:80' depends_on: - pg: + postgres: condition: service_healthy + profiles: ["main"] - pg: - image: postgres:15-alpine + postgres: + container_name: postgres + image: postgres:16-alpine user: postgres restart: always - container_name: postgres_container - environment: - POSTGRES_USER: ${POSTGRES_USER:-postgres} - POSTGRES_DB: ${POSTGRES_DB:-multiTenant_dev} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + env_file: + - .env + volumes: + - pgdata:/var/lib/postgresql/data + expose: + - ${POSTGRES_PORT:-5432} + healthcheck: + test: ['CMD', 'pg_isready'] + interval: 10s + timeout: 5s + retries: 5 + profiles: ["main"] + + test: + build: + context: . + env_file: + - ./__tests__/.env + volumes: + - .:/usr/src/app + - /usr/src/app/node_modules + - /usr/src/app/.pnpm-store + command: pnpm test + depends_on: + testdb: + condition: service_healthy + profiles: ["test"] + + testdb: + container_name: testdb + image: postgres:16-alpine + user: postgres + restart: always + env_file: + - ./__tests__/.env volumes: - - ./db/db-data:/var/lib/postgresql/data + - pgtest:/var/lib/postgresql/data expose: - ${POSTGRES_PORT:-5432} healthcheck: - test: [ "CMD", "pg_isready" ] + test: ['CMD', 'pg_isready'] interval: 10s timeout: 5s retries: 5 + profiles: ["test"] volumes: - db-data: + pgtest: + pgdata: pgadmin: diff --git a/jest.config.ts b/jest.config.ts index 985812a..9caf3f4 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -3,45 +3,20 @@ * https://jestjs.io/docs/configuration */ -import type {Config} from 'jest'; +import type { Config } from 'jest'; const config: Config = { - // All imported modules in your tests should be mocked automatically - // automock: false, - - // Stop running tests after `n` failures - // bail: 0, - - // The directory where Jest should store its cached dependency information - // cacheDirectory: "/private/var/folders/04/pllw_vxs0mn8hz26hrhh5l400000gn/T/jest_dx", - // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, // Indicates whether the coverage information should be collected while executing the test collectCoverage: true, - // An array of glob patterns indicating a set of files for which coverage information should be collected - // collectCoverageFrom: undefined, - // The directory where Jest should output its coverage files - coverageDirectory: "coverage", - - // An array of regexp pattern strings used to skip coverage collection - // coveragePathIgnorePatterns: [ - // "/node_modules/" - // ], + coverageDirectory: 'coverage', // Indicates which provider should be used to instrument code for coverage - coverageProvider: "v8", - - // A list of reporter names that Jest uses when writing coverage reports - // coverageReporters: [ - // "json", - // "text", - // "lcov", - // "clover" - // ], + coverageProvider: 'v8', // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { @@ -59,154 +34,15 @@ const config: Config = { }, }, - // A path to a custom dependency extractor - // dependencyExtractor: undefined, - - // Make calling deprecated APIs throw helpful error messages - // errorOnDeprecated: false, - - // The default configuration for fake timers - // fakeTimers: { - // "enableGlobally": false - // }, - - // Force coverage collection from ignored files using an array of glob patterns - // forceCoverageMatch: [], - - // A path to a module which exports an async function that is triggered once before all test suites - // globalSetup: undefined, - - // A path to a module which exports an async function that is triggered once after all test suites - // globalTeardown: undefined, - - // A set of global variables that need to be available in all test environments - // globals: {}, - - // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. - // maxWorkers: "50%", - - // An array of directory names to be searched recursively up from the requiring module's location - // moduleDirectories: [ - // "node_modules" - // ], - - // An array of file extensions your modules use - // moduleFileExtensions: [ - // "js", - // "mjs", - // "cjs", - // "jsx", - // "ts", - // "tsx", - // "json", - // "node" - // ], - - // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module - // moduleNameMapper: {}, - - // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader - // modulePathIgnorePatterns: [], - - // Activates notifications for test results - // notify: false, - - // An enum that specifies notification mode. Requires { notify: true } - // notifyMode: "failure-change", - // A preset that is used as a base for Jest's configuration preset: 'ts-jest', - // Run tests from one or more projects - // projects: undefined, - - // Use this configuration option to add custom reporters to Jest - // reporters: undefined, - - // Automatically reset mock state before every test - // resetMocks: false, - - // Reset the module registry before running each individual test - // resetModules: false, - - // A path to a custom resolver - // resolver: undefined, - - // Automatically restore mock state and implementation before every test - // restoreMocks: false, - - // The root directory that Jest should scan for tests and modules within - // rootDir: undefined, - - // A list of paths to directories that Jest should use to search for files in - // roots: [ - // "" - // ], - - // Allows you to use a custom runner instead of Jest's default test runner - // runner: "jest-runner", - - // The paths to modules that run some code to configure or set up the testing environment before each test - // setupFiles: [], - - // A list of paths to modules that run some code to configure or set up the testing framework before each test - // setupFilesAfterEnv: [], - - // The number of seconds after which a test is considered as slow and reported as such in the results. - // slowTestThreshold: 5, - - // A list of paths to snapshot serializer modules Jest should use for snapshot testing - // snapshotSerializers: [], - // The test environment that will be used for testing - testEnvironment: "node", - - // Options that will be passed to the testEnvironment - // testEnvironmentOptions: {}, - - // Adds a location field to test results - // testLocationInResults: false, - - // The glob patterns Jest uses to detect test files - // testMatch: [ - // "**/__tests__/**/*.[jt]s?(x)", - // "**/?(*.)+(spec|test).[tj]s?(x)" - // ], - - // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped - // testPathIgnorePatterns: [ - // "/node_modules/" - // ], - - // The regexp pattern or array of patterns that Jest uses to detect test files - // testRegex: [], - - // This option allows the use of a custom results processor - // testResultsProcessor: undefined, - - // This option allows use of a custom test runner - // testRunner: "jest-circus/runner", - - // A map from regular expressions to paths to transformers - // transform: undefined, - - // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation - // transformIgnorePatterns: [ - // "/node_modules/", - // "\\.pnp\\.[^\\/]+$" - // ], - - // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them - // unmockedModulePathPatterns: undefined, - - // Indicates whether each individual test should be reported during the run - // verbose: undefined, - - // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode - // watchPathIgnorePatterns: [], + testEnvironment: 'node', - // Whether to use watchman for file crawling - // watchman: true, + // Global setup and teardown + globalSetup: './__tests__/jest.setup.ts', + globalTeardown: './__tests__/jest.teardown.ts', }; export default config; diff --git a/knexfile.ts b/knexfile.ts index d95a570..36d92a1 100644 --- a/knexfile.ts +++ b/knexfile.ts @@ -1,12 +1,12 @@ -import { Knex } from "knex"; +import { Knex } from 'knex'; const config: Knex.Config = { client: 'pg', connection: process.env.DATABASE_URL, migrations: { - directory: "./src/migrations", - extension: "ts", + directory: './src/db/migrations', + extension: 'ts', }, }; -export default config; \ No newline at end of file +export default config; diff --git a/package.json b/package.json index 27f4b04..6ff2f7b 100644 --- a/package.json +++ b/package.json @@ -4,20 +4,21 @@ "description": "Multi-tenant Node.js Application", "main": "src/index.js", "scripts": { - "test": "jest", "prepare": "husky install", - "start": "node dist/index.js", "db:drop": "knex migrate:down", "db:migrate": "knex migrate:latest", + "docker": "docker compose --profile main up -d", + "start": "pnpm db:migrate && node dist/index.js", "eslint": "eslint src/*.ts --fix --cache --quiet", "build": "rimraf dist && swc ./src -d dist && pnpm eslint", "docker:db-drop": "docker compose run backend pnpm db:drop", - "docker:db-migrate": "docker compose run backend pnpm db:migrate", - "docker:start": "pnpm build && node --inspect=0.0.0.0 dist/index.js", "push:test": "jest --no-coverage --passWithNoTests --changedSince origin/main", - "db:console": "docker compose run pg psql -h pg -U postgres -d multiTenant_dev", + "docker:test": "docker compose --env-file ./__tests__/.env --profile test up -d", + "docker:start": "pnpm db:migrate && pnpm build && node --inspect=0.0.0.0 dist/index.js", "docker:pgadmin": "docker compose up -d pgadmin && open http://localhost:5050/browser/", - "rebuild:be": "docker compose build backend && docker compose rm --force --stop backend && docker compose up -d backend" + "db:console": "docker compose run postgres psql -h postgres -U postgres -e postgres -d multiTenant_dev", + "rebuild:be": "docker compose build backend && docker compose rm --force --stop backend && docker compose up -d backend", + "test": "pnpm db:migrate && pnpm build && jest -i --coverage && docker compose down rm --force --stop test && docker volume rm pgtest" }, "lint-staged": { "src/*.{js,jsx,ts,tsx}": "eslint --cache --fix", @@ -41,6 +42,7 @@ "node": "20.10.0" }, "devDependencies": { + "@faker-js/faker": "^8.3.1", "@jest/globals": "^29.7.0", "@swc/cli": "^0.1.63", "@swc/core": "^1.3.101", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b558ccc..5a7ce07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ dependencies: version: 5.3.3 devDependencies: + '@faker-js/faker': + specifier: ^8.3.1 + version: 8.3.1 '@jest/globals': specifier: ^29.7.0 version: 29.7.0 @@ -533,6 +536,11 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@faker-js/faker@8.3.1: + resolution: {integrity: sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'} + dev: true + /@humanwhocodes/config-array@0.11.13: resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9b93838..354874a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,8 +1,6 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema - generator client { provider = "prisma-client-js" + output = "../node_modules/.prisma/client" } datasource db { @@ -13,13 +11,13 @@ datasource db { model Hostname { id String @id fqdn String @unique @db.VarChar(255) - redirect_to String + redirect_to String force_https Boolean @default(false) under_maintenance_since DateTime? - uuid String @db.Uuid @unique + db_name String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - deletedAt DateTime? + deletedAt DateTime? @@map(name: "hostnames") // use "hostnames" as the table name -} \ No newline at end of file +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index ab425d5..30498fa 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,9 +1,28 @@ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { PrismaClient } from '@prisma/client'; import { nanoid } from 'nanoid'; +import { PrismaClient } from '../../node_modules/.prisma/client'; -const db = new PrismaClient({ log: ['error', 'info', 'query', 'warn'] }); +const prisma = new PrismaClient({ log: ['error', 'info', 'query', 'warn'] }); -export default db; -export const genId = () => nanoid(16); \ No newline at end of file +export default prisma; +export const genId = () => nanoid(16); + +/* + * Execute a raw query + * + * @param command string + * + * @returns Promise + * Executes a prepared raw query and returns the number of affected rows. + */ +export const executeQuery = async (command: string): Promise => { + try { + // Execute the SQL command + const result = await prisma.$executeRawUnsafe(command); + + return result; + } catch (error) { + throw new Error(`Execute Query Error: ${error.message}`); + } +}; diff --git a/src/index.ts b/src/index.ts index 1d19bdd..1094a0c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,47 @@ +// /* eslint-disable no-console */ +// import { genId } from './helpers'; +// import { ITenant } from '../src/modules/types'; +// import { tenants } from './modules/tenant'; + +// export async function TenantActions() { +// if (process.env.MANAGE_TENANTS === 'true') { +// const newTenant: ITenant = { +// // db_name: 'TEST', +// fqdn: `${genId()}.com`, +// force_https: true, +// createdAt: new Date(), +// updatedAt: new Date(), +// redirect_to: 'test.com', +// }; + +// const newTenant2: ITenant = { +// db_name: genId(), +// fqdn: `${genId()}.com`, +// force_https: true, +// createdAt: new Date(), +// updatedAt: new Date(), +// redirect_to: 'google.com', +// }; + +// let tenantID = ''; + +// console.log('Creating tenant...'); +// tenantID = await tenants.createTenant(newTenant); + +// console.log(`Updating tenant ${tenantID}`); +// newTenant.db_name = genId(); +// await tenants.updateTenant(tenantID, newTenant); + +// console.log('Creating second tenant...'); +// tenantID = await tenants.createTenant(newTenant2); + +// console.log('Deleting second tenant...'); +// await tenants.deleteTenant(tenantID); +// } +// } + +// void TenantActions(); + const greeting = 'world'; export function hello(world: string = greeting): string { diff --git a/src/migrations/20240103173520_init-db.ts b/src/migrations/20240103173520_init-db.ts deleted file mode 100644 index a3fb7be..0000000 --- a/src/migrations/20240103173520_init-db.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { Knex } from 'knex'; - -export async function up(knex: Knex): Promise { - await knex.schema.createTable('hostnames', (table) => { - table.specificType('id', 'CHAR(16)').primary(); - table.string('fqdn', 255).notNullable().unique(); - table.text('redirect_to').notNullable(); - table.boolean('force_https').notNullable().defaultTo(false); - table.timestamp('under_maintenance_since'); - table.uuid('uuid').notNullable().unique(); - table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now()); - table.timestamp('updatedAt').notNullable(); - table.timestamp('deletedAt'); - }); -} - -export async function down(knex: Knex): Promise { - await knex.schema.dropTable('hostnames'); -} \ No newline at end of file diff --git a/src/modules/cli.ts b/src/modules/cli.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/tenant.ts b/src/modules/tenant.ts new file mode 100644 index 0000000..493368b --- /dev/null +++ b/src/modules/tenant.ts @@ -0,0 +1,166 @@ +import { ITenant } from './types'; +import prisma, { executeQuery, genId } from '../helpers'; + +export const tenants = { + /** + * Check if tenant exists based on fqdn or db_name + * + * @param db_name string {optional} + * @param fqdn string {optional} + * @param tenantID string {optional} + * + * @returns Promise + */ + async tenantExists( + db_name?: string, + fqdn?: string, + tenantID?: string, + ): Promise { + try { + // Check if tenant exists + const tenant = await prisma.hostname.findFirst({ + where: { + OR: [{ fqdn }, { db_name }, { id: tenantID }], + }, + }); + + return tenant !== null ? tenant : null; + } catch (error) { + throw new Error(`Tenant Exists Error: ${error.message}`); + } + }, + + /* + * Create a new tenant + * + * @param tenant ITenant + * + * @returns Promise + */ + async createTenant(tenant: ITenant): Promise { + // Check if tenant object is empty + if (Object.keys(tenant).length === 0) { + throw new Error('Tenant object is empty'); + } + + // Check if tenant exists + const tenantExist = await this.tenantExists(tenant.db_name, tenant.fqdn); + if (tenantExist !== null) { + throw new Error('Tenant already exists'); + } + + try { + const tenantID = genId(); + const db_name = tenant.db_name || genId(); + + // Create tenant hostname + await prisma.hostname.create({ + data: { + db_name, + id: tenantID, + fqdn: tenant.fqdn, + redirect_to: tenant.redirect_to, + force_https: tenant.force_https, + under_maintenance_since: tenant.under_maintenance_since, + }, + }); + + // Create tenant database + await executeQuery(`CREATE DATABASE "${db_name}";`); + + // Return the tenant id + return tenantID; + } catch (error) { + throw new Error(`Create Tenant Error: ${(error as Error).message}`); + } + }, + + /** + * Delete a tenant + * + * @param tenantID string + * + * @returns Promise + */ + async deleteTenant(tenantID: string): Promise { + // Check if tenant ID is empty + if (tenantID === '') { + throw new Error('Tenant ID is empty'); + } + + // Check if tenant exists + const tenant = await this.tenantExists(undefined, undefined, tenantID); + if (tenant === null) { + throw new Error('Tenant does not exist'); + } + + try { + // Delete the tenant hostname + await prisma.hostname.delete({ + where: { + id: tenantID, + }, + }); + + // Drop the tenant database + await executeQuery(`DROP DATABASE "${tenant.db_name}";`); + + return true; + } catch (error) { + throw new Error(`Delete Tenant Error: ${(error as Error).message}`); + } + }, + + /* + * Update a tenant + * + * @param tenantID string + * @param tenant ITenant + * + * @returns Promise + */ + async updateTenant(tenantID: string, tenant: ITenant): Promise { + // Check if tenant ID is empty + if (tenantID === '') { + throw new Error('Tenant ID is empty'); + } + + // Check if tenant object is empty + if (Object.keys(tenant).length === 0) { + throw new Error('Tenant object is empty'); + } + + // Check if tenant exists + const tenantExist = await this.tenantExists(undefined, undefined, tenantID); + if (tenantExist === null) { + throw new Error('Tenant does not exist'); + } + + try { + const db_name = tenant.db_name || genId(); + + // Update the tenant + await prisma.hostname.update({ + where: { + id: tenantID, + }, + data: { + db_name, + fqdn: tenant.fqdn, + redirect_to: tenant.redirect_to, + force_https: tenant.force_https, + under_maintenance_since: tenant.under_maintenance_since, + }, + }); + + // Rename the tenant database + await executeQuery( + `ALTER DATABASE "${tenantExist.db_name}" RENAME TO "${db_name}";`, + ); + + return true; + } catch (error) { + throw new Error(`Update Tenant Error: ${(error as Error).message}`); + } + }, +}; diff --git a/src/modules/types.ts b/src/modules/types.ts new file mode 100644 index 0000000..0d1cc1b --- /dev/null +++ b/src/modules/types.ts @@ -0,0 +1,11 @@ +export interface ITenant { + id?: string; + fqdn: string; + createdAt: Date; + updatedAt: Date; + redirect_to: string; + force_https: boolean; + db_name?: string; + deletedAt?: Date | null; + under_maintenance_since?: Date | null; +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8255dbd..e3c2f80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,10 +4,11 @@ "lib": [ "ESNext" ], - "outDir": "dist" + "outDir": "dist", + "useUnknownInCatchVariables": false }, "include": [ - "src", + "src", "__tests__", ], "exclude": [ "node_modules",