Selfosted operations module#1553
Conversation
…d configuration checks
…dd cascade delete option to ConnectionEntity
There was a problem hiding this comment.
Pull request overview
This PR introduces a self-hosted operations module to handle initial setup and configuration of self-hosted instances. The module provides endpoints to check if an instance is configured and to create the initial admin user account. The PR also includes database migrations to add CASCADE delete options for better data integrity when companies or AI chat entities are deleted.
Changes:
- Added a new
SelfHostedOperationsModulewith dynamic registration that conditionally loads based on SaaS vs self-hosted mode - Implemented two endpoints:
/selfhosted/is-configured(GET) and/selfhosted/initial-user(POST) for initial instance setup - Added database migrations to add CASCADE delete options to connection, AI chat, and AI chat message entities
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 11 comments.
Show a summary per file
| File | Description |
|---|---|
| backend/src/selfhosted-operations/selhosted-operations.module.ts | Module definition with dynamic registration logic (contains filename typo) |
| backend/src/selfhosted-operations/selfhosted-operations.controller.ts | Controller defining self-hosted setup endpoints |
| backend/src/selfhosted-operations/application/use-cases/is-configured.use.case.ts | Use case to check if instance has been configured |
| backend/src/selfhosted-operations/application/use-cases/create-initial-user.use.case.ts | Use case to create the first admin user |
| backend/src/selfhosted-operations/application/use-cases/selfhosted-use-cases.interfaces.ts | Interface definitions for use cases |
| backend/src/selfhosted-operations/application/responce-objects/is-configured.ro.ts | Response object for configuration status (contains directory name typo) |
| backend/src/selfhosted-operations/application/dto/create-initial-admin-user.dto.ts | DTO with validation for initial user creation |
| backend/src/selfhosted-operations/application/data-structures/create-initial-user.ds.ts | Data structure for user creation |
| backend/src/migrations/1770045005400-AddCascadeOptionToConnectionEntity.ts | Migration to add CASCADE delete to connection entity |
| backend/src/migrations/1770043047971-AddedCascadeOptionToAiChatEntities.ts | Migration to add CASCADE delete to AI chat entities |
| backend/src/exceptions/text/messages.ts | Added error messages for self-hosted operations |
| backend/src/entities/connection/connection.entity.ts | Added CASCADE delete option for company relationship |
| backend/src/entities/ai/ai-conversation-history/user-ai-chat/user-ai-chat.entity.ts | Added CASCADE delete option for user relationship |
| backend/src/entities/ai/ai-conversation-history/ai-chat-messages/ai-chat-message.entity.ts | Added CASCADE delete option for chat relationship |
| backend/src/common/data-injection.tokens.ts | Added dependency injection tokens for new use cases |
| backend/src/app.module.ts | Integrated SelfHostedOperationsModule (contains import typo) |
| backend/test/ava-tests/non-saas-tests/non-saas-selfhosted-operations-e2e.test.ts | E2E tests for self-hosted operations (all tests skipped) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -0,0 +1,45 @@ | |||
| import { DynamicModule, Module } from '@nestjs/common'; | |||
There was a problem hiding this comment.
The filename "selhosted-operations.module.ts" contains a typo - it should be "selfhosted-operations.module.ts" (missing the 'f'). This inconsistency with the module class name SelfHostedOperationsModule and the directory name selfhosted-operations should be corrected.
| import { Body, Controller, Get, HttpStatus, Inject, Post, UseInterceptors } from '@nestjs/common'; | ||
| import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; | ||
| import { SentryInterceptor } from '../interceptors/index.js'; | ||
| import { IsConfiguredRo } from './application/responce-objects/is-configured.ro.js'; |
There was a problem hiding this comment.
The import path contains a typo - "responce-objects" should be "response-objects" (correct spelling). Update this import path once the directory name is corrected to match the codebase convention.
| import { IsConfiguredRo } from './application/responce-objects/is-configured.ro.js'; | |
| import { IsConfiguredRo } from './application/response-objects/is-configured.ro.js'; |
| @@ -0,0 +1,12 @@ | |||
| import { InTransactionEnum } from '../../../enums/index.js'; | |||
| import { SimpleFoundUserInfoDs } from '../../../entities/user/dto/found-user.dto.js'; | |||
| import { IsConfiguredRo } from '../responce-objects/is-configured.ro.js'; | |||
There was a problem hiding this comment.
The import path contains a typo - "responce-objects" should be "response-objects" (correct spelling). Update this import path once the directory name is corrected to match the codebase convention.
| import { IsConfiguredRo } from '../responce-objects/is-configured.ro.js'; | |
| import { IsConfiguredRo } from '../response-objects/is-configured.ro.js'; |
| import AbstractUseCase from '../../../common/abstract-use.case.js'; | ||
| import { IGlobalDatabaseContext } from '../../../common/application/global-database-context.interface.js'; | ||
| import { BaseType } from '../../../common/data-injection.tokens.js'; | ||
| import { IsConfiguredRo } from '../responce-objects/is-configured.ro.js'; |
There was a problem hiding this comment.
The import path contains a typo - "responce-objects" should be "response-objects" (correct spelling). Update this import path once the directory name is corrected to match the codebase convention.
| import { IsConfiguredRo } from '../responce-objects/is-configured.ro.js'; | |
| import { IsConfiguredRo } from '../response-objects/is-configured.ro.js'; |
| test.skip(`${currentTest} should return isConfigured false when no users exist`, async (t) => { | ||
| const dataSource = app.get<DataSource>(BaseType.DATA_SOURCE); | ||
|
|
||
| await clearDatabase(dataSource); | ||
|
|
||
| const result = await request(app.getHttpServer()) | ||
| .get('/selfhosted/is-configured') | ||
| .set('Content-Type', 'application/json') | ||
| .set('Accept', 'application/json'); | ||
|
|
||
| t.is(result.status, 200); | ||
| const responseBody = JSON.parse(result.text); | ||
| console.log('🚀 ~ responseBody:', responseBody); | ||
| t.is(responseBody.isConfigured, false); | ||
| t.pass(); | ||
| }); | ||
|
|
||
| test.skip(`${currentTest} should return isConfigured true when users exist`, async (t) => { | ||
| const dataSource = app.get<DataSource>(BaseType.DATA_SOURCE); | ||
| const userRepository = dataSource.getRepository(UserEntity); | ||
| const companyRepository = dataSource.getRepository(CompanyInfoEntity); | ||
|
|
||
| await clearDatabase(dataSource); | ||
|
|
||
| const company = companyRepository.create({ | ||
| id: faker.string.uuid(), | ||
| name: faker.company.name(), | ||
| }); | ||
| await companyRepository.save(company); | ||
|
|
||
| const user = userRepository.create({ | ||
| email: faker.internet.email().toLowerCase(), | ||
| password: 'TestPassword123!', | ||
| isActive: true, | ||
| company: company, | ||
| }); | ||
| await userRepository.save(user); | ||
|
|
||
| const result = await request(app.getHttpServer()) | ||
| .get('/selfhosted/is-configured') | ||
| .set('Content-Type', 'application/json') | ||
| .set('Accept', 'application/json'); | ||
|
|
||
| t.is(result.status, 200); | ||
| const responseBody = JSON.parse(result.text); | ||
| t.is(responseBody.isConfigured, true); | ||
| t.pass(); | ||
| }); | ||
|
|
||
| currentTest = 'POST /selfhosted/initial-user'; | ||
|
|
||
| test.skip(`${currentTest} should create initial user when instance is not configured`, async (t) => { | ||
| const dataSource = app.get<DataSource>(BaseType.DATA_SOURCE); | ||
| const userRepository = dataSource.getRepository(UserEntity); | ||
|
|
||
| await clearDatabase(dataSource); | ||
|
|
||
| const email = faker.internet.email().toLowerCase(); | ||
| const password = 'UserPassword123!'; | ||
|
|
||
| const result = await request(app.getHttpServer()) | ||
| .post('/selfhosted/initial-user') | ||
| .send({ email, password }) | ||
| .set('Content-Type', 'application/json') | ||
| .set('Accept', 'application/json'); | ||
|
|
||
| const responseBody = JSON.parse(result.text); | ||
| console.log('🚀 ~ responseBody:', responseBody); | ||
|
|
||
| t.is(result.status, 201); | ||
| t.is(responseBody.email, email); | ||
| t.is(Object.hasOwn(responseBody, 'id'), true); | ||
| t.is(responseBody.isActive, true); | ||
|
|
||
| const createdUser = await userRepository.findOne({ where: { email } }); | ||
| t.truthy(createdUser); | ||
| t.is(createdUser.email, email); | ||
| t.pass(); | ||
| }); | ||
|
|
||
| test.skip(`${currentTest} should return error when instance is already configured`, async (t) => { | ||
| const dataSource = app.get<DataSource>(BaseType.DATA_SOURCE); | ||
| const userRepository = dataSource.getRepository(UserEntity); | ||
| const companyRepository = dataSource.getRepository(CompanyInfoEntity); | ||
|
|
||
| await clearDatabase(dataSource); | ||
|
|
||
| const company = companyRepository.create({ | ||
| id: faker.string.uuid(), | ||
| name: faker.company.name(), | ||
| }); | ||
| await companyRepository.save(company); | ||
|
|
||
| const existingUser = userRepository.create({ | ||
| email: faker.internet.email().toLowerCase(), | ||
| password: 'ExistingPassword123!', | ||
| isActive: true, | ||
| company: company, | ||
| }); | ||
| await userRepository.save(existingUser); | ||
|
|
||
| const newEmail = faker.internet.email().toLowerCase(); | ||
| const newPassword = 'NewUserPassword123!'; | ||
|
|
||
| const result = await request(app.getHttpServer()) | ||
| .post('/selfhosted/initial-user') | ||
| .send({ email: newEmail, password: newPassword }) | ||
| .set('Content-Type', 'application/json') | ||
| .set('Accept', 'application/json'); | ||
|
|
||
| t.is(result.status, 400); | ||
| const responseBody = JSON.parse(result.text); | ||
| t.is(responseBody.message, Messages.SELF_HOSTED_ALREADY_CONFIGURED); | ||
| t.pass(); | ||
| }); | ||
|
|
||
| test.skip(`${currentTest} should return validation error for invalid email`, async (t) => { | ||
| const dataSource = app.get<DataSource>(BaseType.DATA_SOURCE); | ||
|
|
||
| await clearDatabase(dataSource); | ||
|
|
||
| const invalidEmail = 'invalid-email'; | ||
| const password = 'UserPassword123!'; | ||
|
|
||
| const result = await request(app.getHttpServer()) | ||
| .post('/selfhosted/initial-user') | ||
| .send({ email: invalidEmail, password }) | ||
| .set('Content-Type', 'application/json') | ||
| .set('Accept', 'application/json'); | ||
|
|
||
| t.is(result.status, 400); | ||
| t.pass(); | ||
| }); | ||
|
|
||
| test.skip(`${currentTest} should return validation error for short password`, async (t) => { | ||
| const dataSource = app.get<DataSource>(BaseType.DATA_SOURCE); | ||
|
|
||
| await clearDatabase(dataSource); | ||
|
|
||
| const email = faker.internet.email().toLowerCase(); | ||
| const shortPassword = 'short'; | ||
|
|
||
| const result = await request(app.getHttpServer()) | ||
| .post('/selfhosted/initial-user') | ||
| .send({ email, password: shortPassword }) | ||
| .set('Content-Type', 'application/json') | ||
| .set('Accept', 'application/json'); | ||
|
|
||
| t.is(result.status, 400); | ||
| t.pass(); | ||
| }); | ||
|
|
||
| test.skip(`${currentTest} should return validation error when email is missing`, async (t) => { | ||
| const dataSource = app.get<DataSource>(BaseType.DATA_SOURCE); | ||
|
|
||
| await clearDatabase(dataSource); | ||
|
|
||
| const password = 'UserPassword123!'; | ||
|
|
||
| const result = await request(app.getHttpServer()) | ||
| .post('/selfhosted/initial-user') | ||
| .send({ password }) | ||
| .set('Content-Type', 'application/json') | ||
| .set('Accept', 'application/json'); | ||
|
|
||
| t.is(result.status, 400); | ||
| t.pass(); | ||
| }); | ||
|
|
||
| test.skip(`${currentTest} should return validation error when password is missing`, async (t) => { | ||
| const dataSource = app.get<DataSource>(BaseType.DATA_SOURCE); | ||
|
|
||
| await clearDatabase(dataSource); | ||
|
|
||
| const email = faker.internet.email().toLowerCase(); | ||
|
|
||
| const result = await request(app.getHttpServer()) | ||
| .post('/selfhosted/initial-user') | ||
| .send({ email }) | ||
| .set('Content-Type', 'application/json') | ||
| .set('Accept', 'application/json'); | ||
|
|
||
| t.is(result.status, 400); | ||
| t.pass(); | ||
| }); |
There was a problem hiding this comment.
All tests in this file are skipped using test.skip. If these tests are ready to run, they should be enabled (remove .skip). If they are not yet ready or there's a known issue preventing them from running, consider adding a comment explaining why they're skipped, or move them to a separate WIP file.
| @MinLength(8) | ||
| @MaxLength(255) | ||
| readonly password: string; |
There was a problem hiding this comment.
The password validation for the initial admin user only uses @minlength(8) and @maxlength(255), but doesn't enforce password strength requirements. Other password operations in the codebase (password reset at backend/src/entities/user/dto/password.dto.ts:9-15, password change at backend/src/entities/user/application/data-structures/change-usual-user-password.ds.ts:15-21) use @IsStrongPassword with requirements for minLowercase, minUppercase, minNumbers. Since this creates the first admin account with full system access, consider using the same @IsStrongPassword validation for better security.
| import { SavedDbQueryModule } from './entities/visualizations/saved-db-query/saved-db-query.module.js'; | ||
| import { DashboardModule } from './entities/visualizations/dashboard/dashboards.module.js'; | ||
| import { DashboardWidgetModule } from './entities/visualizations/dashboard-widget/dashboard-widget.module.js'; | ||
| import { SelfHostedOperationsModule } from './selfhosted-operations/selhosted-operations.module.js'; |
There was a problem hiding this comment.
The import path contains a typo - "selhosted-operations.module.js" should be "selfhosted-operations.module.js" (missing the 'f'). This needs to be corrected along with the actual filename.
| import { SelfHostedOperationsModule } from './selfhosted-operations/selhosted-operations.module.js'; | |
| import { SelfHostedOperationsModule } from './selfhosted-operations/selfhosted-operations.module.js'; |
| @@ -0,0 +1,6 @@ | |||
| import { ApiProperty } from '@nestjs/swagger'; | |||
There was a problem hiding this comment.
The directory name "responce-objects" contains a typo - it should be "response-objects" (correct spelling). The codebase consistently uses "response-objects" elsewhere (e.g., backend/src/entities/ai/ai-conversation-history/application/response-objects/user-ai-chat.ro.js, backend/src/entities/table-filters/application/response-objects/created-table-filters.ro.js). This deviation from the established convention needs to be corrected.
|
|
||
| t.is(result.status, 200); | ||
| const responseBody = JSON.parse(result.text); | ||
| console.log('🚀 ~ responseBody:', responseBody); |
There was a problem hiding this comment.
Debug console.log statement should be removed before merging. This appears to be leftover from development/debugging.
| console.log('🚀 ~ responseBody:', responseBody); |
| .set('Accept', 'application/json'); | ||
|
|
||
| const responseBody = JSON.parse(result.text); | ||
| console.log('🚀 ~ responseBody:', responseBody); |
There was a problem hiding this comment.
Debug console.log statement should be removed before merging. This appears to be leftover from development/debugging.
| console.log('🚀 ~ responseBody:', responseBody); |
No description provided.