Add Tenant initialisation service (#2100)

* Add Tenant initialisation service

* few fixes

* fix constraint

* fix tests

* update metadata json with employees and address

* add V2

* remove metadata.gql
This commit is contained in:
Weiko 2023-10-18 18:01:52 +02:00 committed by GitHub
parent 1cd91e60fa
commit 7fbef6d60d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 513 additions and 177 deletions

View File

@ -1,4 +1,4 @@
import { Global, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { AbilityFactory } from 'src/ability/ability.factory';
import { PrismaService } from 'src/database/prisma.service';
@ -129,7 +129,6 @@ import {
ReadApiKeyAbilityHandler,
} from './handlers/api-key.ability-handler';
@Global()
@Module({
providers: [
AbilityFactory,

View File

@ -1,10 +1,14 @@
import { Module } from '@nestjs/common';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { ActivityResolver } from './resolvers/activity.resolver';
import { ActivityService } from './services/activity.service';
import { ActivityTargetService } from './services/activity-target.service';
@Module({
imports: [AbilityModule, PrismaModule],
providers: [ActivityResolver, ActivityService, ActivityTargetService],
exports: [ActivityService, ActivityTargetService],
})

View File

@ -2,11 +2,14 @@ import { Module } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { TokenService } from 'src/core/auth/services/token.service';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { ApiKeyResolver } from './api-key.resolver';
import { ApiKeyService } from './api-key.service';
@Module({
imports: [AbilityModule, PrismaModule],
providers: [ApiKeyResolver, ApiKeyService, TokenService, JwtService],
})
export class ApiKeyModule {}

View File

@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { FileUploadService } from 'src/core/file/services/file-upload.service';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { AttachmentResolver } from './resolvers/attachment.resolver';
import { AttachmentService } from './services/attachment.service';
@Module({
imports: [AbilityModule, PrismaModule],
providers: [AttachmentService, AttachmentResolver, FileUploadService],
exports: [AttachmentService],
})

View File

@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { CommentService } from './comment.service';
import { CommentResolver } from './comment.resolver';
@Module({
imports: [AbilityModule, PrismaModule],
providers: [CommentService, CommentResolver],
exports: [CommentService],
})

View File

@ -2,13 +2,15 @@ import { Module } from '@nestjs/common';
import { CommentModule } from 'src/core/comment/comment.module';
import { ActivityModule } from 'src/core/activity/activity.module';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { CompanyService } from './company.service';
import { CompanyResolver } from './company.resolver';
import { CompanyRelationsResolver } from './company-relations.resolver';
@Module({
imports: [CommentModule, ActivityModule],
imports: [CommentModule, ActivityModule, AbilityModule, PrismaModule],
providers: [CompanyService, CompanyResolver, CompanyRelationsResolver],
exports: [CompanyService],
})

View File

@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { FavoriteResolver } from './resolvers/favorite.resolver';
import { FavoriteService } from './services/favorite.service';
@Module({
imports: [AbilityModule, PrismaModule],
providers: [FavoriteService, FavoriteResolver],
exports: [FavoriteService],
})

View File

@ -3,13 +3,21 @@ import { Module } from '@nestjs/common';
import { CommentModule } from 'src/core/comment/comment.module';
import { ActivityModule } from 'src/core/activity/activity.module';
import { FileModule } from 'src/core/file/file.module';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { PersonService } from './person.service';
import { PersonResolver } from './person.resolver';
import { PersonRelationsResolver } from './person-relations.resolver';
@Module({
imports: [CommentModule, ActivityModule, FileModule],
imports: [
CommentModule,
ActivityModule,
FileModule,
AbilityModule,
PrismaModule,
],
providers: [PersonService, PersonResolver, PersonRelationsResolver],
exports: [PersonService],
})

View File

@ -1,5 +1,8 @@
import { Module } from '@nestjs/common';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { PipelineService } from './services/pipeline.service';
import { PipelineResolver } from './resolvers/pipeline.resolver';
import { PipelineStageResolver } from './resolvers/pipeline-stage.resolver';
@ -8,7 +11,7 @@ import { PipelineStageService } from './services/pipeline-stage.service';
import { PipelineProgressService } from './services/pipeline-progress.service';
@Module({
imports: [],
imports: [AbilityModule, PrismaModule],
providers: [
PipelineService,
PipelineStageService,

View File

@ -3,12 +3,20 @@ import { Module } from '@nestjs/common';
import { FileModule } from 'src/core/file/file.module';
import { WorkspaceModule } from 'src/core/workspace/workspace.module';
import { EnvironmentModule } from 'src/integrations/environment/environment.module';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { UserService } from './user.service';
import { UserResolver } from './user.resolver';
@Module({
imports: [FileModule, WorkspaceModule, EnvironmentModule],
imports: [
FileModule,
WorkspaceModule,
EnvironmentModule,
AbilityModule,
PrismaModule,
],
providers: [UserService, UserResolver],
exports: [UserService],
})

View File

@ -1,5 +1,8 @@
import { Module } from '@nestjs/common';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { ViewFieldService } from './services/view-field.service';
import { ViewFieldResolver } from './resolvers/view-field.resolver';
import { ViewSortService } from './services/view-sort.service';
@ -10,6 +13,7 @@ import { ViewFilterService } from './services/view-filter.service';
import { ViewFilterResolver } from './resolvers/view-filter.resolver';
@Module({
imports: [AbilityModule, PrismaModule],
providers: [
ViewService,
ViewFieldService,

View File

@ -8,7 +8,7 @@ import { PersonService } from 'src/core/person/person.service';
import { CompanyService } from 'src/core/company/company.service';
import { PipelineProgressService } from 'src/core/pipeline/services/pipeline-progress.service';
import { ViewService } from 'src/core/view/services/view.service';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { TenantInitialisationService } from 'src/metadata/tenant-initialisation/tenant-initialisation.service';
import { WorkspaceService } from './workspace.service';
@ -48,7 +48,7 @@ describe('WorkspaceService', () => {
useValue: {},
},
{
provide: DataSourceService,
provide: TenantInitialisationService,
useValue: {},
},
],

View File

@ -11,7 +11,7 @@ import { PipelineService } from 'src/core/pipeline/services/pipeline.service';
import { ViewService } from 'src/core/view/services/view.service';
import { PrismaService } from 'src/database/prisma.service';
import { assert } from 'src/utils/assert';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { TenantInitialisationService } from 'src/metadata/tenant-initialisation/tenant-initialisation.service';
@Injectable()
export class WorkspaceService {
@ -23,7 +23,7 @@ export class WorkspaceService {
private readonly pipelineStageService: PipelineStageService,
private readonly pipelineProgressService: PipelineProgressService,
private readonly viewService: ViewService,
private readonly dataSourceService: DataSourceService,
private readonly tenantInitialisationService: TenantInitialisationService,
) {}
// Find
@ -66,7 +66,7 @@ export class WorkspaceService {
});
// Create workspace schema
await this.dataSourceService.createWorkspaceSchema(workspace.id);
await this.tenantInitialisationService.init(workspace.id);
// Create default companies
const companies = await this.companyService.createDefaultCompanies({

View File

@ -5,7 +5,9 @@ import { PipelineModule } from 'src/core/pipeline/pipeline.module';
import { CompanyModule } from 'src/core/company/company.module';
import { PersonModule } from 'src/core/person/person.module';
import { ViewModule } from 'src/core/view/view.module';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { TenantInitialisationModule } from 'src/metadata/tenant-initialisation/tenant-initialisation.module';
import { AbilityModule } from 'src/ability/ability.module';
import { PrismaModule } from 'src/database/prisma.module';
import { WorkspaceService } from './services/workspace.service';
import { WorkspaceMemberService } from './services/workspace-member.service';
@ -14,11 +16,13 @@ import { WorkspaceResolver } from './resolvers/workspace.resolver';
@Module({
imports: [
AbilityModule,
PipelineModule,
CompanyModule,
PersonModule,
ViewModule,
DataSourceModule,
TenantInitialisationModule,
PrismaModule,
],
providers: [
WorkspaceService,

View File

@ -1,8 +1,7 @@
import { Global, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],

View File

@ -33,14 +33,6 @@ export const seedWorkspaces = async (prisma: PrismaClient) => {
'80f5e1e3-574a-4bf9-b5bc-98aedd2b76e6', 'workspace_twenty', 'postgres', 'twenty-dev-7ed9d212-1c25-4d02-bf25-6aeccf7ea420'
) ON CONFLICT DO NOTHING`,
);
await prisma.$queryRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_twenty.tenant_migrations (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
migrations JSONB,
applied_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await prisma.$queryRawUnsafe(
'CREATE SCHEMA IF NOT EXISTS workspace_twenty_7icsva0r6s00mpcp6cwg4w4rd',
@ -53,12 +45,4 @@ export const seedWorkspaces = async (prisma: PrismaClient) => {
'b37b2163-7f63-47a9-b1b3-6c7290ca9fb1', 'workspace_twenty_7icsva0r6s00mpcp6cwg4w4rd', 'postgres', 'twenty-7ed9d212-1c25-4d02-bf25-6aeccf7ea419'
) ON CONFLICT DO NOTHING`,
);
await prisma.$queryRawUnsafe(`
CREATE TABLE IF NOT EXISTS workspace_twenty_7icsva0r6s00mpcp6cwg4w4rd.tenant_migrations (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
migrations JSONB,
applied_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
};

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { PrismaModule } from 'src/database/prisma.module';
import { HealthController } from 'src/health/health.controller';
import { PrismaHealthIndicator } from 'src/health/indicators/prisma-health-indicator';
@Module({
imports: [TerminusModule],
imports: [TerminusModule, PrismaModule],
controllers: [HealthController],
providers: [PrismaHealthIndicator],
})

View File

@ -1,6 +1,6 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { DataSource, QueryRunner, Table } from 'typeorm';
import { DataSource } from 'typeorm';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
@ -37,55 +37,14 @@ export class DataSourceService implements OnModuleInit, OnModuleDestroy {
const schemaAlreadyExists = await queryRunner.hasSchema(schemaName);
if (schemaAlreadyExists) {
return schemaName;
throw new Error(`Schema ${schemaName} already exists`);
}
await queryRunner.createSchema(schemaName, true);
await this.createMigrationTable(queryRunner, schemaName);
await queryRunner.release();
await this.dataSourceMetadataService.createDataSourceMetadata(
workspaceId,
schemaName,
);
return schemaName;
}
private async createMigrationTable(
queryRunner: QueryRunner,
schemaName: string,
) {
await queryRunner.createTable(
new Table({
name: 'tenant_migrations',
schema: schemaName,
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
default: 'uuid_generate_v4()',
},
{
name: 'migrations',
type: 'jsonb',
},
{
name: 'applied_at',
type: 'timestamp',
isNullable: true,
},
{
name: 'created_at',
type: 'timestamp',
default: 'now()',
},
],
}),
);
}
/**
* Connects to a workspace data source using the workspace metadata. Returns a cached connection if it exists.
* @param workspaceId

View File

@ -8,6 +8,7 @@ import {
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import {
@ -38,6 +39,7 @@ export type FieldMetadataTargetColumnMap = {
disableFilter: true,
disableSort: true,
})
@Unique('IndexOnNameAndWorkspaceIdUnique', ['name', 'objectId', 'workspaceId'])
export class FieldMetadata {
@IDField(() => ID)
@PrimaryGeneratedColumn('uuid')

View File

@ -10,13 +10,13 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import {
convertFieldMetadataToColumnChanges,
convertFieldMetadataToColumnActions,
generateTargetColumnMap,
} from 'src/metadata/field-metadata/utils/field-metadata.util';
import { MigrationRunnerService } from 'src/metadata/migration-runner/migration-runner.service';
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service';
import { TenantMigrationTableChange } from 'src/metadata/tenant-migration/tenant-migration.entity';
import { TenantMigrationTableAction } from 'src/metadata/tenant-migration/tenant-migration.entity';
@Injectable()
export class FieldMetadataService extends TypeOrmQueryService<FieldMetadata> {
@ -59,13 +59,16 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadata> {
targetColumnMap: generateTargetColumnMap(record.type),
});
await this.tenantMigrationService.createMigration(record.workspaceId, [
{
name: objectMetadata.targetTableName,
change: 'alter',
columns: convertFieldMetadataToColumnChanges(createdFieldMetadata),
} satisfies TenantMigrationTableChange,
]);
await this.tenantMigrationService.createCustomMigration(
record.workspaceId,
[
{
name: objectMetadata.targetTableName,
action: 'alter',
columns: convertFieldMetadataToColumnActions(createdFieldMetadata),
} satisfies TenantMigrationTableAction,
],
);
await this.migrationRunnerService.executeMigrationFromPendingMigrations(
record.workspaceId,

View File

@ -5,7 +5,7 @@ import {
FieldMetadata,
FieldMetadataTargetColumnMap,
} from 'src/metadata/field-metadata/field-metadata.entity';
import { TenantMigrationColumnChange } from 'src/metadata/tenant-migration/tenant-migration.entity';
import { TenantMigrationColumnAction } from 'src/metadata/tenant-migration/tenant-migration.entity';
/**
* Generate a column name from a field name removing unsupported characters.
@ -52,15 +52,15 @@ export function generateTargetColumnMap(
}
}
export function convertFieldMetadataToColumnChanges(
export function convertFieldMetadataToColumnActions(
fieldMetadata: FieldMetadata,
): TenantMigrationColumnChange[] {
): TenantMigrationColumnAction[] {
switch (fieldMetadata.type) {
case 'text':
return [
{
name: fieldMetadata.targetColumnMap.value,
change: 'create',
action: 'create',
type: 'text',
},
];
@ -69,7 +69,7 @@ export function convertFieldMetadataToColumnChanges(
return [
{
name: fieldMetadata.targetColumnMap.value,
change: 'create',
action: 'create',
type: 'varchar',
},
];
@ -77,7 +77,7 @@ export function convertFieldMetadataToColumnChanges(
return [
{
name: fieldMetadata.targetColumnMap.value,
change: 'create',
action: 'create',
type: 'integer',
},
];
@ -85,7 +85,7 @@ export function convertFieldMetadataToColumnChanges(
return [
{
name: fieldMetadata.targetColumnMap.value,
change: 'create',
action: 'create',
type: 'boolean',
},
];
@ -93,7 +93,7 @@ export function convertFieldMetadataToColumnChanges(
return [
{
name: fieldMetadata.targetColumnMap.value,
change: 'create',
action: 'create',
type: 'timestamp',
},
];
@ -101,12 +101,12 @@ export function convertFieldMetadataToColumnChanges(
return [
{
name: fieldMetadata.targetColumnMap.text,
change: 'create',
action: 'create',
type: 'varchar',
},
{
name: fieldMetadata.targetColumnMap.link,
change: 'create',
action: 'create',
type: 'varchar',
},
];
@ -114,12 +114,12 @@ export function convertFieldMetadataToColumnChanges(
return [
{
name: fieldMetadata.targetColumnMap.amount,
change: 'create',
action: 'create',
type: 'integer',
},
{
name: fieldMetadata.targetColumnMap.currency,
change: 'create',
action: 'create',
type: 'varchar',
},
];

View File

@ -11,6 +11,8 @@ import { MetadataNameLabelRefactoring1697126636202 } from './migrations/16971266
import { RemoveFieldMetadataPlaceholder1697471445015 } from './migrations/1697471445015-removeFieldMetadataPlaceholder';
import { AddSoftDelete1697474804403 } from './migrations/1697474804403-addSoftDelete';
import { RemoveSingularPluralFromFieldLabelAndName1697534910933 } from './migrations/1697534910933-removeSingularPluralFromFieldLabelAndName';
import { AddNameAndIsCustomToTenantMigration1697622715467 } from './migrations/1697622715467-addNameAndIsCustomToTenantMigration';
import { AddUniqueConstraintsOnFieldObjectMetadata1697630766924 } from './migrations/1697630766924-addUniqueConstraintsOnFieldObjectMetadata';
config();
@ -33,6 +35,8 @@ export const typeORMMetadataModuleOptions: TypeOrmModuleOptions = {
RemoveFieldMetadataPlaceholder1697471445015,
AddSoftDelete1697474804403,
RemoveSingularPluralFromFieldLabelAndName1697534910933,
AddNameAndIsCustomToTenantMigration1697622715467,
AddUniqueConstraintsOnFieldObjectMetadata1697630766924,
],
};

View File

@ -4,8 +4,8 @@ import { QueryRunner, Table, TableColumn } from 'typeorm';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import {
TenantMigrationTableChange,
TenantMigrationColumnChange,
TenantMigrationTableAction,
TenantMigrationColumnAction,
} from 'src/metadata/tenant-migration/tenant-migration.entity';
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
@ -20,11 +20,11 @@ export class MigrationRunnerService {
* Executes pending migrations for a given workspace
*
* @param workspaceId string
* @returns Promise<TenantMigrationTableChange[]>
* @returns Promise<TenantMigrationTableAction[]>
*/
public async executeMigrationFromPendingMigrations(
workspaceId: string,
): Promise<TenantMigrationTableChange[]> {
): Promise<TenantMigrationTableAction[]> {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
@ -35,7 +35,7 @@ export class MigrationRunnerService {
const pendingMigrations =
await this.tenantMigrationService.getPendingMigrations(workspaceId);
const flattenedPendingMigrations: TenantMigrationTableChange[] =
const flattenedPendingMigrations: TenantMigrationTableAction[] =
pendingMigrations.reduce((acc, pendingMigration) => {
return [...acc, ...pendingMigration.migrations];
}, []);
@ -71,9 +71,9 @@ export class MigrationRunnerService {
private async handleTableChanges(
queryRunner: QueryRunner,
schemaName: string,
tableMigration: TenantMigrationTableChange,
tableMigration: TenantMigrationTableAction,
) {
switch (tableMigration.change) {
switch (tableMigration.action) {
case 'create':
await this.createTable(queryRunner, schemaName, tableMigration.name);
break;
@ -87,7 +87,7 @@ export class MigrationRunnerService {
break;
default:
throw new Error(
`Migration table change ${tableMigration.change} not supported`,
`Migration table action ${tableMigration.action} not supported`,
);
}
}
@ -142,21 +142,21 @@ export class MigrationRunnerService {
* @param queryRunner QueryRunner
* @param schemaName string
* @param tableName string
* @param columnMigrations TenantMigrationColumnChange[]
* @param columnMigrations TenantMigrationColumnAction[]
* @returns
*/
private async handleColumnChanges(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
columnMigrations?: TenantMigrationColumnChange[],
columnMigrations?: TenantMigrationColumnAction[],
) {
if (!columnMigrations || columnMigrations.length === 0) {
return;
}
for (const columnMigration of columnMigrations) {
switch (columnMigration.change) {
switch (columnMigration.action) {
case 'create':
await this.createColumn(
queryRunner,
@ -165,13 +165,9 @@ export class MigrationRunnerService {
columnMigration,
);
break;
case 'alter':
throw new Error(
`Migration column change ${columnMigration.change} not supported`,
);
default:
throw new Error(
`Migration column change ${columnMigration.change} not supported`,
`Migration column action ${columnMigration.action} not supported`,
);
}
}
@ -183,13 +179,13 @@ export class MigrationRunnerService {
* @param queryRunner QueryRunner
* @param schemaName string
* @param tableName string
* @param migrationColumn TenantMigrationColumnChange
* @param migrationColumn TenantMigrationColumnAction
*/
private async createColumn(
queryRunner: QueryRunner,
schemaName: string,
tableName: string,
migrationColumn: TenantMigrationColumnChange,
migrationColumn: TenantMigrationColumnAction,
) {
await queryRunner.addColumn(
`${schemaName}.${tableName}`,

View File

@ -0,0 +1,55 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddNameAndIsCustomToTenantMigration1697622715467
implements MigrationInterface
{
name = 'AddNameAndIsCustomToTenantMigration1697622715467';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" DROP COLUMN "applied_at"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" DROP COLUMN "created_at"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" ADD "name" character varying`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" ADD "isCustom" boolean NOT NULL DEFAULT false`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" ADD "appliedAt" TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" ADD "workspaceId" character varying NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" ADD "createdAt" TIMESTAMP NOT NULL DEFAULT now()`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" DROP COLUMN "createdAt"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" DROP COLUMN "workspaceId"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" DROP COLUMN "appliedAt"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" DROP COLUMN "isCustom"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" DROP COLUMN "name"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" ADD "created_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."tenant_migrations" ADD "applied_at" TIMESTAMP`,
);
}
}

View File

@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUniqueConstraintsOnFieldObjectMetadata1697630766924
implements MigrationInterface
{
name = 'AddUniqueConstraintsOnFieldObjectMetadata1697630766924';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."object_metadata" DROP CONSTRAINT "UQ_8b063d2a685474dbae56cd685d2"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."object_metadata" DROP CONSTRAINT "UQ_a2387e1b21120110b7e3db83da1"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."field_metadata" ADD CONSTRAINT "IndexOnNameObjectIdAndWorkspaceIdUnique" UNIQUE ("name", "object_id", "workspace_id")`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."object_metadata" ADD CONSTRAINT "IndexOnNamePluralAndWorkspaceIdUnique" UNIQUE ("name_plural", "workspace_id")`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."object_metadata" ADD CONSTRAINT "IndexOnNameSingularAndWorkspaceIdUnique" UNIQUE ("name_singular", "workspace_id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."object_metadata" DROP CONSTRAINT "IndexOnNameSingularAndWorkspaceIdUnique"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."object_metadata" DROP CONSTRAINT "IndexOnNamePluralAndWorkspaceIdUnique"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."field_metadata" DROP CONSTRAINT "IndexOnNameAndWorkspaceIdUnique"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."object_metadata" ADD CONSTRAINT "UQ_a2387e1b21120110b7e3db83da1" UNIQUE ("name_plural")`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."object_metadata" ADD CONSTRAINT "UQ_8b063d2a685474dbae56cd685d2" UNIQUE ("name_singular")`,
);
}
}

View File

@ -7,6 +7,7 @@ import {
Entity,
OneToMany,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import {
@ -36,6 +37,11 @@ import { BeforeCreateOneObject } from './hooks/before-create-one-object.hook';
disableSort: true,
})
@CursorConnection('fields', () => FieldMetadata)
@Unique('IndexOnNameSingularAndWorkspaceIdUnique', [
'nameSingular',
'workspaceId',
])
@Unique('IndexOnNamePluralAndWorkspaceIdUnique', ['namePlural', 'workspaceId'])
export class ObjectMetadata {
@IDField(() => ID)
@PrimaryGeneratedColumn('uuid')
@ -46,11 +52,11 @@ export class ObjectMetadata {
dataSourceId: string;
@Field()
@Column({ nullable: false, name: 'name_singular', unique: true })
@Column({ nullable: false, name: 'name_singular' })
nameSingular: string;
@Field()
@Column({ nullable: false, name: 'name_plural', unique: true })
@Column({ nullable: false, name: 'name_plural' })
namePlural: string;
@Field()

View File

@ -5,7 +5,7 @@ import { Repository } from 'typeorm';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
import { TenantMigrationTableChange } from 'src/metadata/tenant-migration/tenant-migration.entity';
import { TenantMigrationTableAction } from 'src/metadata/tenant-migration/tenant-migration.entity';
import { MigrationRunnerService } from 'src/metadata/migration-runner/migration-runner.service';
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
@ -24,13 +24,13 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadata> {
override async createOne(record: ObjectMetadata): Promise<ObjectMetadata> {
const createdObjectMetadata = await super.createOne(record);
await this.tenantMigrationService.createMigration(
await this.tenantMigrationService.createCustomMigration(
createdObjectMetadata.workspaceId,
[
{
name: createdObjectMetadata.targetTableName,
change: 'create',
} satisfies TenantMigrationTableChange,
action: 'create',
} satisfies TenantMigrationTableAction,
],
);

View File

@ -0,0 +1,55 @@
{
"nameSingular": "companyV2",
"namePlural": "companiesV2",
"labelSingular": "Company",
"labelPlural": "Companies",
"targetTableName": "company",
"description": "A company",
"icon": "business",
"fields": [
{
"type": "text",
"name": "name",
"label": "Name",
"targetColumnMap": {
"value": "name"
},
"description": "Name of the company",
"icon": null,
"isNullable": false
},
{
"type": "text",
"name": "domainName",
"label": "Domain Name",
"targetColumnMap": {
"value": "domainName"
},
"description": "Domain name of the company",
"icon": "url",
"isNullable": true
},
{
"type": "text",
"name": "address",
"label": "Address",
"targetColumnMap": {
"value": "address"
},
"description": "Address of the company",
"icon": "location",
"isNullable": true
},
{
"type": "number",
"name": "employees",
"label": "Employees",
"targetColumnMap": {
"value": "employees"
},
"description": "Number of employees",
"icon": "people",
"isNullable": true
}
]
}

View File

@ -0,0 +1,5 @@
import companyObject from './companies.metadata.json';
export const standardObjectsMetadata = {
companyV2: companyObject,
};

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { MigrationRunnerModule } from 'src/metadata/migration-runner/migration-runner.module';
import { TenantMigrationModule } from 'src/metadata/tenant-migration/tenant-migration.module';
import { FieldMetadataModule } from 'src/metadata/field-metadata/field-metadata.module';
import { ObjectMetadataModule } from 'src/metadata/object-metadata/object-metadata.module';
import { DataSourceMetadataModule } from 'src/metadata/data-source-metadata/data-source-metadata.module';
import { TenantInitialisationService } from './tenant-initialisation.service';
@Module({
imports: [
DataSourceModule,
TenantMigrationModule,
MigrationRunnerModule,
ObjectMetadataModule,
FieldMetadataModule,
DataSourceMetadataModule,
],
exports: [TenantInitialisationService],
providers: [TenantInitialisationService],
})
export class TenantInitialisationModule {}

View File

@ -0,0 +1,93 @@
import { Injectable } from '@nestjs/common';
import { TenantMigrationService } from 'src/metadata/tenant-migration/tenant-migration.service';
import { MigrationRunnerService } from 'src/metadata/migration-runner/migration-runner.service';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { FieldMetadataService } from 'src/metadata/field-metadata/services/field-metadata.service';
import { ObjectMetadataService } from 'src/metadata/object-metadata/services/object-metadata.service';
import { DataSourceMetadataService } from 'src/metadata/data-source-metadata/data-source-metadata.service';
import { FieldMetadata } from 'src/metadata/field-metadata/field-metadata.entity';
import { ObjectMetadata } from 'src/metadata/object-metadata/object-metadata.entity';
import { standardObjectsMetadata } from './standard-objects/standard-object-metadata';
@Injectable()
export class TenantInitialisationService {
constructor(
private readonly dataSourceService: DataSourceService,
private readonly tenantMigrationService: TenantMigrationService,
private readonly migrationRunnerService: MigrationRunnerService,
private readonly objectMetadataService: ObjectMetadataService,
private readonly fieldMetadataService: FieldMetadataService,
private readonly dataSourceMetadataService: DataSourceMetadataService,
) {}
/**
* Init a workspace by creating a new data source and running all migrations
* @param workspaceId
* @returns Promise<void>
*/
public async init(workspaceId: string): Promise<void> {
const schemaName = await this.dataSourceService.createWorkspaceSchema(
workspaceId,
);
const dataSourceMetadata =
await this.dataSourceMetadataService.createDataSourceMetadata(
workspaceId,
schemaName,
);
await this.tenantMigrationService.insertStandardMigrations(workspaceId);
// Todo: keep in mind that we don't handle concurrency issues such as migrations being created at the same time
// but it shouldn't be the role of this service to handle this kind of issues for now.
// To check later when we run this in a job.
await this.migrationRunnerService.executeMigrationFromPendingMigrations(
workspaceId,
);
await this.createObjectsAndFieldsMetadata(
dataSourceMetadata.id,
workspaceId,
);
}
/**
*
* Create all standard objects and fields metadata for a given workspace
*
* @param dataSourceMetadataId
* @param workspaceId
*/
private async createObjectsAndFieldsMetadata(
dataSourceMetadataId: string,
workspaceId: string,
) {
const createdObjectMetadata = await this.objectMetadataService.createMany(
Object.values(standardObjectsMetadata).map((objectMetadata) => ({
...objectMetadata,
dataSourceId: dataSourceMetadataId,
fields: [],
workspaceId,
isCustom: false,
isActive: true,
})),
);
await this.fieldMetadataService.createMany(
createdObjectMetadata.flatMap((objectMetadata: ObjectMetadata) =>
standardObjectsMetadata[objectMetadata.nameSingular].fields.map(
(field: FieldMetadata) => ({
...field,
objectId: objectMetadata.id,
dataSourceId: dataSourceMetadataId,
workspaceId,
isCustom: false,
isActive: true,
}),
),
),
);
}
}

View File

@ -0,0 +1,34 @@
import { TenantMigrationTableAction } from 'src/metadata/tenant-migration/tenant-migration.entity';
export const addCompanyTable: TenantMigrationTableAction[] = [
{
name: 'company',
action: 'create',
},
{
name: 'company',
action: 'alter',
columns: [
{
name: 'name',
type: 'varchar',
action: 'create',
},
{
name: 'domainName',
type: 'varchar',
action: 'create',
},
{
name: 'address',
type: 'varchar',
action: 'create',
},
{
name: 'employees',
type: 'integer',
action: 'create',
},
],
},
];

View File

@ -0,0 +1,6 @@
import { addCompanyTable } from './migrations/1697618009-addCompanyTable';
// TODO: read the folder and return all migrations
export const standardMigrations = {
'1697618009-addCompanyTable': addCompanyTable,
};

View File

@ -5,29 +5,37 @@ import {
PrimaryGeneratedColumn,
} from 'typeorm';
export type TenantMigrationColumnChange = {
export type TenantMigrationColumnAction = {
name: string;
type: string;
change: 'create' | 'alter';
action: 'create';
};
export type TenantMigrationTableChange = {
export type TenantMigrationTableAction = {
name: string;
change: 'create' | 'alter';
columns?: TenantMigrationColumnChange[];
action: 'create' | 'alter';
columns?: TenantMigrationColumnAction[];
};
@Entity('tenant_migrations')
export class TenantMigration {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'jsonb' })
migrations: TenantMigrationTableChange[];
migrations: TenantMigrationTableAction[];
@Column({ nullable: true, name: 'applied_at' })
appliedAt: Date;
@Column({ nullable: true })
name: string;
@CreateDateColumn({ name: 'created_at' })
@Column({ default: false })
isCustom: boolean;
@Column({ nullable: true })
appliedAt?: Date;
@Column()
workspaceId: string;
@CreateDateColumn()
createdAt: Date;
}

View File

@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TenantMigrationService } from './tenant-migration.service';
import { TenantMigration } from './tenant-migration.entity';
@Module({
imports: [DataSourceModule],
imports: [TypeOrmModule.forFeature([TenantMigration], 'metadata')],
exports: [TenantMigrationService],
providers: [TenantMigrationService],
})

View File

@ -1,8 +1,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { TenantMigrationService } from './tenant-migration.service';
import { TenantMigration } from './tenant-migration.entity';
describe('TenantMigrationService', () => {
let service: TenantMigrationService;
@ -12,7 +12,7 @@ describe('TenantMigrationService', () => {
providers: [
TenantMigrationService,
{
provide: DataSourceService,
provide: getRepositoryToken(TenantMigration, 'metadata'),
useValue: {},
},
],

View File

@ -1,17 +1,55 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull } from 'typeorm';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { IsNull, Repository } from 'typeorm';
import {
TenantMigration,
TenantMigrationTableChange,
TenantMigrationTableAction,
} from './tenant-migration.entity';
import { standardMigrations } from './standard-migrations';
@Injectable()
export class TenantMigrationService {
constructor(private readonly dataSourceService: DataSourceService) {}
constructor(
@InjectRepository(TenantMigration, 'metadata')
private readonly tenantMigrationRepository: Repository<TenantMigration>,
) {}
/**
* Insert all standard migrations that have not been inserted yet
*
* @param workspaceId
*/
public async insertStandardMigrations(workspaceId: string) {
// TODO: we actually don't need to fetch all of them, to improve later so it scales well.
const insertedStandardMigrations =
await this.tenantMigrationRepository.find({
where: { workspaceId, isCustom: false },
});
const insertedStandardMigrationsMapByName =
insertedStandardMigrations.reduce((acc, migration) => {
acc[migration.name] = migration;
return acc;
}, {});
const standardMigrationsList = standardMigrations;
const standardMigrationsListThatNeedToBeInserted = Object.entries(
standardMigrationsList,
)
.filter(([name]) => !insertedStandardMigrationsMapByName[name])
.map(([name, migrations]) => ({ name, migrations }));
await this.tenantMigrationRepository.save(
standardMigrationsListThatNeedToBeInserted.map((migration) => ({
...migration,
workspaceId,
isCustom: false,
})),
);
}
/**
* Get all pending migrations for a given workspaceId
@ -22,19 +60,12 @@ export class TenantMigrationService {
public async getPendingMigrations(
workspaceId: string,
): Promise<TenantMigration[]> {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
if (!workspaceDataSource) {
throw new Error('Workspace data source not found');
}
const tenantMigrationRepository =
workspaceDataSource.getRepository(TenantMigration);
return tenantMigrationRepository.find({
return this.tenantMigrationRepository.find({
order: { createdAt: 'ASC' },
where: { appliedAt: IsNull() },
where: {
appliedAt: IsNull(),
workspaceId,
},
});
}
@ -49,17 +80,7 @@ export class TenantMigrationService {
workspaceId: string,
migration: TenantMigration,
) {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
if (!workspaceDataSource) {
throw new Error('Workspace data source not found');
}
const tenantMigrationRepository =
workspaceDataSource.getRepository(TenantMigration);
await tenantMigrationRepository.save({
await this.tenantMigrationRepository.save({
id: migration.id,
appliedAt: new Date(),
});
@ -71,22 +92,14 @@ export class TenantMigrationService {
* @param workspaceId
* @param migrations
*/
public async createMigration(
public async createCustomMigration(
workspaceId: string,
migrations: TenantMigrationTableChange[],
migrations: TenantMigrationTableAction[],
) {
const workspaceDataSource =
await this.dataSourceService.connectToWorkspaceDataSource(workspaceId);
if (!workspaceDataSource) {
throw new Error('Workspace data source not found');
}
const tenantMigrationRepository =
workspaceDataSource.getRepository(TenantMigration);
await tenantMigrationRepository.save({
await this.tenantMigrationRepository.save({
migrations,
workspaceId,
isCustom: true,
});
}
}