From de48a912d4c12fe4d37f7be4373aa944deb7c70c Mon Sep 17 00:00:00 2001 From: Andrii Arsenin Date: Tue, 7 Apr 2026 13:00:54 +0300 Subject: [PATCH] feat(environment): add environment CRUD module and wire into auth login --- .env.example | 6 +- src/app.module.ts | 5 +- src/auth/auth.controller.ts | 2 +- src/auth/auth.module.ts | 3 +- src/auth/auth.service.ts | 31 +++++----- src/auth/dto/login.dto.ts | 11 +++- src/environment/dto/create-environment.dto.ts | 21 +++++++ src/environment/dto/update-environment.dto.ts | 4 ++ src/environment/environment.controller.ts | 61 +++++++++++++++++++ src/environment/environment.entity.ts | 32 ++++++++++ src/environment/environment.module.ts | 13 ++++ src/environment/environment.service.ts | 43 +++++++++++++ 12 files changed, 208 insertions(+), 24 deletions(-) create mode 100644 src/environment/dto/create-environment.dto.ts create mode 100644 src/environment/dto/update-environment.dto.ts create mode 100644 src/environment/environment.controller.ts create mode 100644 src/environment/environment.entity.ts create mode 100644 src/environment/environment.module.ts create mode 100644 src/environment/environment.service.ts diff --git a/.env.example b/.env.example index e4d77ae..7dec620 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,4 @@ APP_VERSION=1.0.0 KEYS_DIR=keys # SQLite database path -DB_PATH=data/sessions.db - -# Login automation targets -ID_LOGIN_URL=https://id-liquio-diia-stg.kitsoft.ua/ -CABINET_URL=https://cabinet-liquio-diia-stg.kitsoft.ua/messages +DB_PATH=data/sessions.db \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index cd42142..ad6e97c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,8 @@ import { HealthController } from './health/health.controller'; import { AuthModule } from './auth/auth.module'; import { BrowserModule } from './browser/browser.module'; import { SessionEntity } from './session/session.entity'; +import { EnvironmentEntity } from './environment/environment.entity'; +import { EnvironmentModule } from './environment/environment.module'; @Module({ imports: [ @@ -17,12 +19,13 @@ import { SessionEntity } from './session/session.entity'; useFactory: (config: ConfigService) => ({ type: 'better-sqlite3', database: config.get('DB_PATH', 'data/sessions.db'), - entities: [SessionEntity], + entities: [SessionEntity, EnvironmentEntity], synchronize: true, }), }), AuthModule, BrowserModule, + EnvironmentModule, ], controllers: [HealthController], }) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index c0f74bd..43a7577 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -21,6 +21,6 @@ export class AuthController { @ApiResponse({ status: 400, description: 'Key not found or invalid' }) @ApiResponse({ status: 500, description: 'Automation failed' }) login(@Body() dto: LoginDto): Promise<{ token: string; sessionName: string }> { - return this.authService.login(dto.key, dto.sessionName); + return this.authService.login(dto.key, dto.environmentName, dto.sessionName); } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 9d7f7ac..f276c2b 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { SessionModule } from '../session/session.module'; +import { EnvironmentModule } from '../environment/environment.module'; @Module({ - imports: [SessionModule], + imports: [SessionModule, EnvironmentModule], controllers: [AuthController], providers: [AuthService], exports: [AuthService], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index d564e80..de0dc6e 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -3,6 +3,7 @@ import { BadRequestException, InternalServerErrorException, Logger, + NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { chromium } from 'playwright'; @@ -10,6 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { SessionService } from '../session/session.service'; +import { EnvironmentService } from '../environment/environment.service'; interface KeyDescriptor { keyFile: string; @@ -20,24 +22,15 @@ interface KeyDescriptor { export class AuthService { private readonly logger = new Logger(AuthService.name); private readonly keysDir: string; - private readonly loginUrl: string; - private readonly cabinetUrl: string; constructor( private readonly config: ConfigService, private readonly sessionService: SessionService, + private readonly environmentService: EnvironmentService, ) { this.keysDir = path.resolve( this.config.get('KEYS_DIR', 'keys'), ); - this.loginUrl = this.config.get( - 'ID_LOGIN_URL', - 'https://id-liquio-diia-stg.kitsoft.ua/', - ); - this.cabinetUrl = this.config.get( - 'CABINET_URL', - 'https://cabinet-liquio-diia-stg.kitsoft.ua/messages', - ); } listKeys(): string[] { @@ -47,9 +40,17 @@ export class AuthService { .map(f => path.basename(f, '.json')); } - async login(keyId: string, sessionName?: string): Promise<{ token: string; sessionName: string }> { + async login(keyId: string, environmentName: string, sessionName?: string): Promise<{ token: string; sessionName: string }> { const resolvedSession = sessionName ?? crypto.randomUUID(); + const env = await this.environmentService.findAll().then(all => all.find(e => e.name === environmentName)); + if (!env) throw new NotFoundException(`Environment "${environmentName}" not found`); + + const loginUrl = env.urls.id_url; + const cabinetUrl = env.urls.cabinet_url; + if (!loginUrl) throw new BadRequestException(`Environment "${environmentName}" is missing id_url`); + if (!cabinetUrl) throw new BadRequestException(`Environment "${environmentName}" is missing cabinet_url`); + const keyJsonPath = path.join(this.keysDir, `${keyId}.json`); if (!fs.existsSync(keyJsonPath)) { @@ -73,8 +74,8 @@ export class AuthService { const context = await browser.newContext(); const page = await context.newPage(); - this.logger.log(`Navigating to ${this.loginUrl}`); - await page.goto(this.loginUrl, { waitUntil: 'networkidle' }); + this.logger.log(`Navigating to ${loginUrl}`); + await page.goto(loginUrl, { waitUntil: 'networkidle' }); // Click "Файловий ключ" button await page.getByText('Файловий ключ').click(); @@ -92,8 +93,8 @@ export class AuthService { await page.locator('#id-app-login-file-key-sign-button').click(); // Wait until redirected to cabinet - this.logger.log(`Waiting for redirect to ${this.cabinetUrl}`); - await page.waitForURL(this.cabinetUrl, { timeout: 30000 }); + this.logger.log(`Waiting for redirect to ${cabinetUrl}`); + await page.waitForURL(cabinetUrl, { timeout: 30000 }); // Extract token from localStorage const token = await page.evaluate(() => localStorage.getItem('token')); diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index dfc3ca4..ade399b 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsOptional, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; export class LoginDto { @ApiProperty({ @@ -7,8 +7,17 @@ export class LoginDto { example: '3273334361', }) @IsString() + @IsNotEmpty() key: string; + @ApiProperty({ + description: 'Environment name to resolve login/cabinet URLs from', + example: 'liquio-diia-stg', + }) + @IsString() + @IsNotEmpty() + environmentName: string; + @ApiPropertyOptional({ description: 'Session name to store credentials under. Auto-generated UUID if omitted.', example: 'my-test-session', diff --git a/src/environment/dto/create-environment.dto.ts b/src/environment/dto/create-environment.dto.ts new file mode 100644 index 0000000..37915d8 --- /dev/null +++ b/src/environment/dto/create-environment.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsObject, IsString } from 'class-validator'; +import { EnvironmentUrls } from '../environment.entity'; + +export class CreateEnvironmentDto { + @ApiProperty({ example: 'liquio-diia-stg' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + description: 'Map of URL identifiers to URL strings', + example: { + id_url: 'https://id-liquio-diia-stg.kitsoft.ua/', + cabinet_url: 'https://cabinet-liquio-diia-stg.kitsoft.ua/', + admin_url: 'https://admin-liquio-diia-stg.kitsoft.ua/', + }, + }) + @IsObject() + urls: EnvironmentUrls; +} diff --git a/src/environment/dto/update-environment.dto.ts b/src/environment/dto/update-environment.dto.ts new file mode 100644 index 0000000..8cab80b --- /dev/null +++ b/src/environment/dto/update-environment.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateEnvironmentDto } from './create-environment.dto'; + +export class UpdateEnvironmentDto extends PartialType(CreateEnvironmentDto) {} diff --git a/src/environment/environment.controller.ts b/src/environment/environment.controller.ts new file mode 100644 index 0000000..8897d64 --- /dev/null +++ b/src/environment/environment.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseIntPipe, + Patch, + Post, +} from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { EnvironmentService } from './environment.service'; +import { CreateEnvironmentDto } from './dto/create-environment.dto'; +import { UpdateEnvironmentDto } from './dto/update-environment.dto'; + +@ApiTags('environments') +@Controller('environments') +export class EnvironmentController { + constructor(private readonly environmentService: EnvironmentService) {} + + @Post() + @ApiOperation({ summary: 'Create a new environment' }) + @ApiResponse({ status: 201, description: 'Environment created' }) + @ApiResponse({ status: 409, description: 'Environment name already exists' }) + create(@Body() dto: CreateEnvironmentDto) { + return this.environmentService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List all environments' }) + @ApiResponse({ status: 200, description: 'Array of environments' }) + findAll() { + return this.environmentService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get environment by ID' }) + @ApiResponse({ status: 200, description: 'Environment record' }) + @ApiResponse({ status: 404, description: 'Environment not found' }) + findOne(@Param('id', ParseIntPipe) id: number) { + return this.environmentService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an environment' }) + @ApiResponse({ status: 200, description: 'Environment updated' }) + @ApiResponse({ status: 404, description: 'Environment not found' }) + update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateEnvironmentDto) { + return this.environmentService.update(id, dto); + } + + @Delete(':id') + @HttpCode(204) + @ApiOperation({ summary: 'Delete an environment' }) + @ApiResponse({ status: 204, description: 'Environment deleted' }) + @ApiResponse({ status: 404, description: 'Environment not found' }) + remove(@Param('id', ParseIntPipe) id: number) { + return this.environmentService.remove(id); + } +} diff --git a/src/environment/environment.entity.ts b/src/environment/environment.entity.ts new file mode 100644 index 0000000..224c78f --- /dev/null +++ b/src/environment/environment.entity.ts @@ -0,0 +1,32 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export interface EnvironmentUrls { + id_url?: string; + cabinet_url?: string; + admin_url?: string; + [key: string]: string | undefined; +} + +@Entity('environments') +export class EnvironmentEntity { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true }) + name: string; + + @Column('simple-json') + urls: EnvironmentUrls; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/environment/environment.module.ts b/src/environment/environment.module.ts new file mode 100644 index 0000000..6466b42 --- /dev/null +++ b/src/environment/environment.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EnvironmentEntity } from './environment.entity'; +import { EnvironmentService } from './environment.service'; +import { EnvironmentController } from './environment.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([EnvironmentEntity])], + controllers: [EnvironmentController], + providers: [EnvironmentService], + exports: [EnvironmentService], +}) +export class EnvironmentModule {} diff --git a/src/environment/environment.service.ts b/src/environment/environment.service.ts new file mode 100644 index 0000000..2cd5554 --- /dev/null +++ b/src/environment/environment.service.ts @@ -0,0 +1,43 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { EnvironmentEntity } from './environment.entity'; +import { CreateEnvironmentDto } from './dto/create-environment.dto'; +import { UpdateEnvironmentDto } from './dto/update-environment.dto'; + +@Injectable() +export class EnvironmentService { + constructor( + @InjectRepository(EnvironmentEntity) + private readonly repo: Repository, + ) {} + + async create(dto: CreateEnvironmentDto): Promise { + const existing = await this.repo.findOneBy({ name: dto.name }); + if (existing) { + throw new ConflictException(`Environment "${dto.name}" already exists`); + } + return this.repo.save(this.repo.create(dto)); + } + + findAll(): Promise { + return this.repo.find(); + } + + async findOne(id: number): Promise { + const env = await this.repo.findOneBy({ id }); + if (!env) throw new NotFoundException(`Environment ${id} not found`); + return env; + } + + async update(id: number, dto: UpdateEnvironmentDto): Promise { + const env = await this.findOne(id); + Object.assign(env, dto); + return this.repo.save(env); + } + + async remove(id: number): Promise { + await this.findOne(id); + await this.repo.delete(id); + } +}