first commit
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { faker } from '@faker-js/faker';
|
||||
import {
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { getMockProvider } from '@/test-utils/util-functions';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { UserDto } from '../user/dtos';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
EmailUserSignUpRequestDto,
|
||||
EmailVerificationCodeRequestDto,
|
||||
EmailVerificationMailingRequestDto,
|
||||
InvitationUserSignUpRequestDto,
|
||||
OAuthUserSignUpRequestDto,
|
||||
} from './dtos/requests';
|
||||
|
||||
const MockAuthService = {
|
||||
sendEmailCode: jest.fn(),
|
||||
verifyEmailCode: jest.fn(),
|
||||
signUpEmailUser: jest.fn(),
|
||||
signUpInvitationUser: jest.fn(),
|
||||
signUpOAuthUser: jest.fn(),
|
||||
signIn: jest.fn(),
|
||||
signInByOAuth: jest.fn(),
|
||||
refreshToken: jest.fn(),
|
||||
getOAuthLoginURL: jest.fn(),
|
||||
};
|
||||
|
||||
const MockTenantService = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
describe('AuthController', () => {
|
||||
let authController: AuthController;
|
||||
let authService: jest.Mocked<AuthService>;
|
||||
let _tenantService: jest.Mocked<TenantService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
getMockProvider(AuthService, MockAuthService),
|
||||
getMockProvider(TenantService, MockTenantService),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
}).compile();
|
||||
|
||||
authController = module.get(AuthController);
|
||||
authService = module.get(AuthService);
|
||||
_tenantService = module.get(TenantService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(authController).toBeDefined();
|
||||
});
|
||||
describe('sendCode', () => {
|
||||
it('should send email verification code successfully', async () => {
|
||||
const mockTimestamp = DateTime.utc().toISO();
|
||||
const dto = new EmailVerificationMailingRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
|
||||
authService.sendEmailCode.mockResolvedValue(mockTimestamp);
|
||||
|
||||
const result = await authController.sendCode(dto);
|
||||
|
||||
expect(authService.sendEmailCode).toHaveBeenCalledWith(dto);
|
||||
expect(authService.sendEmailCode).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ expiredAt: mockTimestamp });
|
||||
});
|
||||
|
||||
it('should handle sendEmailCode errors', async () => {
|
||||
const dto = new EmailVerificationMailingRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
const error = new InternalServerErrorException(
|
||||
'Email service unavailable',
|
||||
);
|
||||
|
||||
authService.sendEmailCode.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.sendCode(dto)).rejects.toThrow(
|
||||
InternalServerErrorException,
|
||||
);
|
||||
expect(authService.sendEmailCode).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('should handle invalid email format', async () => {
|
||||
const dto = new EmailVerificationMailingRequestDto();
|
||||
dto.email = 'invalid-email';
|
||||
const error = new BadRequestException('Invalid email format');
|
||||
|
||||
authService.sendEmailCode.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.sendCode(dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('verifyEmailCode', () => {
|
||||
it('should verify email code successfully', async () => {
|
||||
const dto = new EmailVerificationCodeRequestDto();
|
||||
dto.code = faker.string.alphanumeric(6);
|
||||
dto.email = faker.internet.email();
|
||||
|
||||
authService.verifyEmailCode.mockResolvedValue(undefined);
|
||||
|
||||
await authController.verifyEmailCode(dto);
|
||||
|
||||
expect(authService.verifyEmailCode).toHaveBeenCalledWith(dto);
|
||||
expect(authService.verifyEmailCode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle invalid verification code', async () => {
|
||||
const dto = new EmailVerificationCodeRequestDto();
|
||||
dto.code = 'invalid-code';
|
||||
dto.email = faker.internet.email();
|
||||
const error = new BadRequestException('Invalid verification code');
|
||||
|
||||
authService.verifyEmailCode.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.verifyEmailCode(dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(authService.verifyEmailCode).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('should handle expired verification code', async () => {
|
||||
const dto = new EmailVerificationCodeRequestDto();
|
||||
dto.code = faker.string.alphanumeric(6);
|
||||
dto.email = faker.internet.email();
|
||||
const error = new BadRequestException('Verification code expired');
|
||||
|
||||
authService.verifyEmailCode.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.verifyEmailCode(dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('signUpEmailUser', () => {
|
||||
it('should sign up email user successfully', async () => {
|
||||
const dto = new EmailUserSignUpRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
|
||||
authService.signUpEmailUser.mockResolvedValue(undefined as any);
|
||||
|
||||
const result = await authController.signUpEmailUser(dto);
|
||||
|
||||
expect(authService.signUpEmailUser).toHaveBeenCalledWith(dto);
|
||||
expect(authService.signUpEmailUser).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle email already exists error', async () => {
|
||||
const dto = new EmailUserSignUpRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
const error = new BadRequestException('Email already exists');
|
||||
|
||||
authService.signUpEmailUser.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.signUpEmailUser(dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(authService.signUpEmailUser).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('should handle weak password error', async () => {
|
||||
const dto = new EmailUserSignUpRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = '123'; // Weak password
|
||||
const error = new BadRequestException('Password is too weak');
|
||||
|
||||
authService.signUpEmailUser.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.signUpEmailUser(dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('signUpInvitationUser', () => {
|
||||
it('should sign up invitation user successfully', async () => {
|
||||
const dto = new InvitationUserSignUpRequestDto();
|
||||
dto.code = faker.string.alphanumeric(8);
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
|
||||
authService.signUpInvitationUser.mockResolvedValue(undefined as any);
|
||||
|
||||
const result = await authController.signUpInvitationUser(dto);
|
||||
|
||||
expect(authService.signUpInvitationUser).toHaveBeenCalledWith(dto);
|
||||
expect(authService.signUpInvitationUser).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle invalid invitation code', async () => {
|
||||
const dto = new InvitationUserSignUpRequestDto();
|
||||
dto.code = 'invalid-code';
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
const error = new BadRequestException('Invalid invitation code');
|
||||
|
||||
authService.signUpInvitationUser.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.signUpInvitationUser(dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(authService.signUpInvitationUser).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('should handle expired invitation', async () => {
|
||||
const dto = new InvitationUserSignUpRequestDto();
|
||||
dto.code = faker.string.alphanumeric(8);
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
const error = new BadRequestException('Invitation has expired');
|
||||
|
||||
authService.signUpInvitationUser.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.signUpInvitationUser(dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('signInEmail', () => {
|
||||
it('should sign in email user successfully', () => {
|
||||
const user = new UserDto();
|
||||
user.id = faker.number.int();
|
||||
user.email = faker.internet.email();
|
||||
user.name = faker.person.fullName();
|
||||
const mockTokens = {
|
||||
accessToken: faker.string.alphanumeric(32),
|
||||
refreshToken: faker.string.alphanumeric(32),
|
||||
};
|
||||
|
||||
authService.signIn.mockReturnValue(mockTokens as any);
|
||||
|
||||
const result = authController.signInEmail(user);
|
||||
|
||||
expect(authService.signIn).toHaveBeenCalledWith(user);
|
||||
expect(authService.signIn).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockTokens);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('should refresh token successfully', () => {
|
||||
const user = new UserDto();
|
||||
user.id = faker.number.int();
|
||||
user.email = faker.internet.email();
|
||||
user.name = faker.person.fullName();
|
||||
const mockTokens = {
|
||||
accessToken: faker.string.alphanumeric(32),
|
||||
refreshToken: faker.string.alphanumeric(32),
|
||||
};
|
||||
|
||||
authService.refreshToken.mockReturnValue(mockTokens as any);
|
||||
|
||||
const result = authController.refreshToken(user);
|
||||
|
||||
expect(authService.refreshToken).toHaveBeenCalledWith(user);
|
||||
expect(authService.refreshToken).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockTokens);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signUpOAuthUser', () => {
|
||||
it('should sign up OAuth user successfully', async () => {
|
||||
const dto = new OAuthUserSignUpRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.projectName = faker.company.name();
|
||||
dto.roleName = faker.person.jobTitle();
|
||||
|
||||
authService.signUpOAuthUser.mockResolvedValue(undefined);
|
||||
|
||||
const result = await authController.signUpOAuthUser(dto);
|
||||
|
||||
expect(authService.signUpOAuthUser).toHaveBeenCalledWith(dto);
|
||||
expect(authService.signUpOAuthUser).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle OAuth provider error', async () => {
|
||||
const dto = new OAuthUserSignUpRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.projectName = faker.company.name();
|
||||
dto.roleName = faker.person.jobTitle();
|
||||
const error = new InternalServerErrorException('OAuth provider error');
|
||||
|
||||
authService.signUpOAuthUser.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.signUpOAuthUser(dto)).rejects.toThrow(
|
||||
InternalServerErrorException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirectToLoginURL', () => {
|
||||
it('should return OAuth login URL', async () => {
|
||||
const callbackUrl = faker.internet.url();
|
||||
const mockUrl = faker.internet.url();
|
||||
|
||||
authService.getOAuthLoginURL.mockResolvedValue(mockUrl);
|
||||
|
||||
const result = await authController.redirectToLoginURL(callbackUrl);
|
||||
|
||||
expect(authService.getOAuthLoginURL).toHaveBeenCalledWith(callbackUrl);
|
||||
expect(authService.getOAuthLoginURL).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ url: mockUrl });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCallback', () => {
|
||||
it('should handle OAuth callback successfully', async () => {
|
||||
const query = { code: faker.string.alphanumeric(32) };
|
||||
const mockTokens = {
|
||||
accessToken: faker.string.alphanumeric(32),
|
||||
refreshToken: faker.string.alphanumeric(32),
|
||||
};
|
||||
|
||||
authService.signInByOAuth.mockResolvedValue(mockTokens);
|
||||
|
||||
const result = await authController.handleCallback(query);
|
||||
|
||||
expect(authService.signInByOAuth).toHaveBeenCalledWith(query.code);
|
||||
expect(authService.signInByOAuth).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(mockTokens);
|
||||
});
|
||||
|
||||
it('should handle OAuth authentication failure', async () => {
|
||||
const query = { code: 'invalid-code' };
|
||||
const error = new BadRequestException('OAuth authentication failed');
|
||||
|
||||
authService.signInByOAuth.mockRejectedValue(error);
|
||||
|
||||
await expect(authController.handleCallback(query)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { CurrentUser } from '../user/decorators';
|
||||
import { UserDto } from '../user/dtos';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
EmailUserSignInRequestDto,
|
||||
EmailUserSignUpRequestDto,
|
||||
EmailVerificationCodeRequestDto,
|
||||
EmailVerificationMailingRequestDto,
|
||||
InvitationUserSignUpRequestDto,
|
||||
OAuthUserSignUpRequestDto,
|
||||
} from './dtos/requests';
|
||||
import {
|
||||
OAuthLoginUrlResponseDto,
|
||||
SendEmailCodeResponseDto,
|
||||
SignInResponseDto,
|
||||
} from './dtos/responses';
|
||||
import { JwtAuthGuard } from './guards';
|
||||
import { UseEmailGuard } from './guards/use-email.guard';
|
||||
import { UseOAuthGuard } from './guards/use-oauth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('/admin/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@ApiCreatedResponse({ type: SendEmailCodeResponseDto })
|
||||
@Post('email/code')
|
||||
async sendCode(@Body() body: EmailVerificationMailingRequestDto) {
|
||||
const expiredAt = await this.authService.sendEmailCode(body);
|
||||
return SendEmailCodeResponseDto.transform({ expiredAt });
|
||||
}
|
||||
|
||||
@HttpCode(200)
|
||||
@Post('email/code/verify')
|
||||
async verifyEmailCode(@Body() body: EmailVerificationCodeRequestDto) {
|
||||
await this.authService.verifyEmailCode(body);
|
||||
}
|
||||
|
||||
@UseGuards(UseEmailGuard)
|
||||
@Post('signUp/email')
|
||||
async signUpEmailUser(@Body() body: EmailUserSignUpRequestDto) {
|
||||
await this.authService.signUpEmailUser(body);
|
||||
}
|
||||
|
||||
@UseGuards(UseEmailGuard)
|
||||
@Post('signUp/invitation')
|
||||
async signUpInvitationUser(@Body() body: InvitationUserSignUpRequestDto) {
|
||||
await this.authService.signUpInvitationUser(body);
|
||||
}
|
||||
|
||||
@UseGuards(UseOAuthGuard)
|
||||
@Post('signUp/oauth')
|
||||
async signUpOAuthUser(@Body() body: OAuthUserSignUpRequestDto) {
|
||||
await this.authService.signUpOAuthUser(body);
|
||||
}
|
||||
|
||||
@ApiBody({ type: EmailUserSignInRequestDto })
|
||||
@ApiCreatedResponse({ type: SignInResponseDto })
|
||||
@Post('signIn/email')
|
||||
@UseGuards(UseEmailGuard, AuthGuard('local'))
|
||||
signInEmail(@CurrentUser() user: UserDto) {
|
||||
return this.authService.signIn(user);
|
||||
}
|
||||
|
||||
@UseGuards(UseOAuthGuard)
|
||||
@ApiQuery({ name: 'callback_url', required: false })
|
||||
@ApiOkResponse({ type: OAuthLoginUrlResponseDto })
|
||||
@Get('signIn/oauth/loginURL')
|
||||
async redirectToLoginURL(@Query('callback_url') callbackUrl: string) {
|
||||
return {
|
||||
url: await this.authService.getOAuthLoginURL(callbackUrl),
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(UseOAuthGuard)
|
||||
@ApiQuery({ name: 'code', required: false })
|
||||
@ApiOkResponse({ type: SignInResponseDto })
|
||||
@Get('signIn/oauth')
|
||||
async handleCallback(@Query() query: { code: string }) {
|
||||
return await this.authService.signInByOAuth(query.code);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@ApiOkResponse({ type: SignInResponseDto })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('refresh')
|
||||
refreshToken(@CurrentUser() user: UserDto) {
|
||||
return this.authService.refreshToken(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
|
||||
import { CodeModule } from '@/shared/code/code.module';
|
||||
import { MailingModule } from '@/shared/mailing/mailing.module';
|
||||
|
||||
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||
import { ApiKeyModule } from '../project/api-key/api-key.module';
|
||||
import { MemberModule } from '../project/member/member.module';
|
||||
import { RoleModule } from '../project/role/role.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { LocalStrategy } from './strategies/local.strategy';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => CodeModule),
|
||||
forwardRef(() => UserModule),
|
||||
forwardRef(() => PassportModule),
|
||||
forwardRef(() => MailingModule),
|
||||
forwardRef(() => ApiKeyModule),
|
||||
forwardRef(() => TenantModule),
|
||||
forwardRef(() => RoleModule),
|
||||
forwardRef(() => MemberModule),
|
||||
HttpModule.register({
|
||||
timeout: 5000,
|
||||
maxRedirects: 5,
|
||||
}),
|
||||
JwtModule.registerAsync({
|
||||
global: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService<ConfigServiceType>) => {
|
||||
const { secret } = configService.get('jwt', { infer: true }) ?? {};
|
||||
return { secret };
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [AuthService, LocalStrategy, JwtStrategy],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { CodeEntity } from '@/shared/code/code.entity';
|
||||
import { NotVerifiedEmailException } from '@/shared/mailing/exceptions';
|
||||
|
||||
import {
|
||||
emailFixture,
|
||||
passwordFixture,
|
||||
userFixture,
|
||||
} from '@/test-utils/fixtures';
|
||||
import type {
|
||||
CodeRepositoryStub,
|
||||
TenantRepositoryStub,
|
||||
} from '@/test-utils/stubs';
|
||||
import { TestConfig } from '@/test-utils/util-functions';
|
||||
import {
|
||||
AuthServiceProviders,
|
||||
MockEmailVerificationMailingService,
|
||||
MockJwtService,
|
||||
} from '../../../test-utils/providers/auth.service.providers';
|
||||
import { ApiKeyEntity } from '../project/api-key/api-key.entity';
|
||||
import { TenantEntity } from '../tenant/tenant.entity';
|
||||
import { UserDto } from '../user/dtos';
|
||||
import {
|
||||
SignUpMethodEnum,
|
||||
UserStateEnum,
|
||||
UserTypeEnum,
|
||||
} from '../user/entities/enums';
|
||||
import { UserEntity } from '../user/entities/user.entity';
|
||||
import {
|
||||
UserAlreadyExistsException,
|
||||
UserNotFoundException,
|
||||
} from '../user/exceptions';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
SendEmailCodeDto,
|
||||
SignUpEmailUserDto,
|
||||
SignUpInvitationUserDto,
|
||||
SignUpOauthUserDto,
|
||||
ValidateEmailUserDto,
|
||||
VerifyEmailCodeDto,
|
||||
} from './dtos';
|
||||
import { PasswordNotMatchException, UserBlockedException } from './exceptions';
|
||||
|
||||
describe('auth service ', () => {
|
||||
let authService: AuthService;
|
||||
let userRepo: Repository<UserEntity>;
|
||||
let tenantRepo: TenantRepositoryStub;
|
||||
let codeRepo: CodeRepositoryStub;
|
||||
let apiKeyRepo: Repository<ApiKeyEntity>;
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [TestConfig, ClsModule.forRoot()],
|
||||
providers: AuthServiceProviders,
|
||||
}).compile();
|
||||
authService = module.get(AuthService);
|
||||
userRepo = module.get(getRepositoryToken(UserEntity));
|
||||
tenantRepo = module.get(getRepositoryToken(TenantEntity));
|
||||
codeRepo = module.get(getRepositoryToken(CodeEntity));
|
||||
apiKeyRepo = module.get(getRepositoryToken(ApiKeyEntity));
|
||||
});
|
||||
|
||||
describe('sendEmailCode', () => {
|
||||
let dto: SendEmailCodeDto;
|
||||
beforeEach(() => {
|
||||
dto = new SendEmailCodeDto();
|
||||
});
|
||||
|
||||
it('sending a code by email succeeds with a valid email', async () => {
|
||||
const validEmail = faker.internet.email();
|
||||
dto.email = validEmail;
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValue(null);
|
||||
jest.spyOn(MockEmailVerificationMailingService, 'send');
|
||||
|
||||
const timeoutTime = await authService.sendEmailCode(dto);
|
||||
|
||||
expect(new Date(timeoutTime) > new Date()).toEqual(true);
|
||||
});
|
||||
it('sending a code by email succeeds with a duplicate email', async () => {
|
||||
const duplicateEmail = emailFixture;
|
||||
dto.email = duplicateEmail;
|
||||
jest.spyOn(MockEmailVerificationMailingService, 'send');
|
||||
|
||||
await expect(authService.sendEmailCode(dto)).rejects.toThrow(
|
||||
UserAlreadyExistsException,
|
||||
);
|
||||
|
||||
expect(MockEmailVerificationMailingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyEmailCode', () => {
|
||||
it('verifying email code succeeds in test environment', async () => {
|
||||
const dto = new VerifyEmailCodeDto();
|
||||
dto.code = faker.string.alphanumeric(6);
|
||||
dto.email = faker.internet.email();
|
||||
|
||||
// In test environment, this method returns undefined
|
||||
const result = await authService.verifyEmailCode(dto);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEmailUser', () => {
|
||||
it('validating a user succeeds with valid inputs', async () => {
|
||||
const dto = new ValidateEmailUserDto();
|
||||
dto.email = emailFixture;
|
||||
dto.password = passwordFixture;
|
||||
|
||||
const result = await authService.validateEmailUser(dto);
|
||||
|
||||
expect(result).toEqual({
|
||||
...userFixture,
|
||||
signUpMethod: SignUpMethodEnum.EMAIL,
|
||||
});
|
||||
});
|
||||
it('validating a user fails with a nonexistent user', async () => {
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValue(null);
|
||||
const dto = new ValidateEmailUserDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = passwordFixture;
|
||||
|
||||
await expect(authService.validateEmailUser(dto)).rejects.toThrow(
|
||||
UserNotFoundException,
|
||||
);
|
||||
});
|
||||
it('validating a user fails with an invalid password', async () => {
|
||||
const invalidPassword = faker.internet.password();
|
||||
const dto = new ValidateEmailUserDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = invalidPassword;
|
||||
|
||||
await expect(authService.validateEmailUser(dto)).rejects.toThrow(
|
||||
PasswordNotMatchException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signUpEmailUser', () => {
|
||||
it('signing up by an email succeeds with valid inputs', async () => {
|
||||
const dto = new SignUpEmailUserDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
codeRepo.setIsVerified(true);
|
||||
jest.spyOn(userRepo, 'findOneBy').mockResolvedValue(null);
|
||||
|
||||
const user = await authService.signUpEmailUser(dto);
|
||||
|
||||
expect(user.signUpMethod).toEqual(SignUpMethodEnum.EMAIL);
|
||||
});
|
||||
it('signing up by an email fails with a not verified email', async () => {
|
||||
const dto = new SignUpEmailUserDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
codeRepo.setIsVerified(false);
|
||||
jest.spyOn(userRepo, 'findOneBy').mockResolvedValue(null);
|
||||
jest.spyOn(userRepo, 'save');
|
||||
|
||||
await expect(authService.signUpEmailUser(dto)).rejects.toThrow(
|
||||
NotVerifiedEmailException,
|
||||
);
|
||||
|
||||
expect(userRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
it('signing up by an email fails with a not verification requested email', async () => {
|
||||
const dto = new SignUpEmailUserDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
codeRepo.setNull();
|
||||
jest.spyOn(userRepo, 'findOneBy').mockResolvedValue(null);
|
||||
jest.spyOn(userRepo, 'save');
|
||||
|
||||
await expect(authService.signUpEmailUser(dto)).rejects.toThrow(
|
||||
new BadRequestException('must request email verification'),
|
||||
);
|
||||
|
||||
expect(userRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signUpInvitationUser', () => {
|
||||
it('signing up by invitation succeeds with valid inputs', async () => {
|
||||
const dto = new SignUpInvitationUserDto();
|
||||
dto.code = codeRepo.entities?.[0]?.code ?? faker.string.alphanumeric(8);
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
codeRepo.setIsVerified(false); // Not verified initially
|
||||
jest.spyOn(userRepo, 'findOneBy').mockResolvedValue(null);
|
||||
|
||||
// Mock the codeService.getDataByCodeAndType to return valid data
|
||||
|
||||
const authServiceAny = authService as any;
|
||||
jest
|
||||
.spyOn(authServiceAny.codeService, 'getDataByCodeAndType')
|
||||
.mockResolvedValue({
|
||||
userType: UserTypeEnum.GENERAL,
|
||||
roleId: faker.number.int(),
|
||||
invitedBy: new UserDto(),
|
||||
} as any);
|
||||
|
||||
// Mock the createUserService to avoid complex dependencies
|
||||
const mockUser = new UserEntity();
|
||||
mockUser.signUpMethod = SignUpMethodEnum.EMAIL;
|
||||
mockUser.email = faker.internet.email();
|
||||
|
||||
jest
|
||||
.spyOn(authServiceAny.createUserService, 'createInvitationUser')
|
||||
.mockResolvedValue(mockUser as any);
|
||||
|
||||
const user = await authService.signUpInvitationUser(dto);
|
||||
|
||||
expect(user.signUpMethod).toEqual(SignUpMethodEnum.EMAIL);
|
||||
});
|
||||
|
||||
it('signing up by invitation fails with invalid invitation code', async () => {
|
||||
const dto = new SignUpInvitationUserDto();
|
||||
dto.code = 'invalid-code';
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
codeRepo.setNull();
|
||||
|
||||
await expect(authService.signUpInvitationUser(dto)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('signing up by invitation fails with already verified code', async () => {
|
||||
const dto = new SignUpInvitationUserDto();
|
||||
dto.code = faker.string.alphanumeric(8);
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = faker.internet.password();
|
||||
codeRepo.setIsVerified(true); // Already verified
|
||||
|
||||
await expect(authService.signUpInvitationUser(dto)).rejects.toThrow(
|
||||
new BadRequestException('already verified'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signUpOAuthUser', () => {
|
||||
it('signing up by OAuth succeeds with valid inputs', async () => {
|
||||
const dto = new SignUpOauthUserDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.projectName = faker.company.name();
|
||||
dto.roleName = faker.person.jobTitle();
|
||||
jest.spyOn(userRepo, 'findOneBy').mockResolvedValue(null);
|
||||
jest.spyOn(userRepo, 'save').mockResolvedValue(new UserEntity());
|
||||
|
||||
await authService.signUpOAuthUser(dto);
|
||||
|
||||
expect(userRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signing up by OAuth fails with existing user', async () => {
|
||||
const dto = new SignUpOauthUserDto();
|
||||
dto.email = emailFixture;
|
||||
dto.projectName = faker.company.name();
|
||||
dto.roleName = faker.person.jobTitle();
|
||||
|
||||
await expect(authService.signUpOAuthUser(dto)).rejects.toThrow(
|
||||
UserAlreadyExistsException,
|
||||
);
|
||||
});
|
||||
|
||||
it('signing up by OAuth succeeds with empty project and role', async () => {
|
||||
const dto = new SignUpOauthUserDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.projectName = '';
|
||||
dto.roleName = '';
|
||||
jest.spyOn(userRepo, 'findOneBy').mockResolvedValue(null);
|
||||
|
||||
const result = await authService.signUpOAuthUser(dto);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signIn', () => {
|
||||
it('signing in succeeds with a valid user', async () => {
|
||||
const activeUser = new UserEntity();
|
||||
activeUser.state = UserStateEnum.Active;
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValue(activeUser);
|
||||
const dto = new UserDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.id = faker.number.int();
|
||||
|
||||
const jwt = await authService.signIn(dto);
|
||||
|
||||
expect(jwt).toHaveProperty('accessToken');
|
||||
expect(jwt).toHaveProperty('refreshToken');
|
||||
});
|
||||
it('signing in fails with a blocked user', async () => {
|
||||
const blockedUser = new UserEntity();
|
||||
blockedUser.state = UserStateEnum.Blocked;
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValue(blockedUser);
|
||||
const dto = new UserDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.id = faker.number.int();
|
||||
|
||||
await expect(authService.signIn(dto)).rejects.toThrow(
|
||||
UserBlockedException,
|
||||
);
|
||||
|
||||
expect(MockJwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('refreshing token succeeds with valid user', async () => {
|
||||
const activeUser = new UserEntity();
|
||||
activeUser.state = UserStateEnum.Active;
|
||||
activeUser.id = faker.number.int();
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValue(activeUser);
|
||||
|
||||
const jwt = await authService.refreshToken({ id: activeUser.id });
|
||||
|
||||
expect(jwt).toHaveProperty('accessToken');
|
||||
expect(jwt).toHaveProperty('refreshToken');
|
||||
expect(MockJwtService.sign).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('refreshing token fails with blocked user', async () => {
|
||||
const blockedUser = new UserEntity();
|
||||
blockedUser.state = UserStateEnum.Blocked;
|
||||
blockedUser.id = faker.number.int();
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValue(blockedUser);
|
||||
|
||||
await expect(
|
||||
authService.refreshToken({ id: blockedUser.id }),
|
||||
).rejects.toThrow(UserBlockedException);
|
||||
|
||||
expect(MockJwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshing token fails with non-existent user', async () => {
|
||||
const userId = faker.number.int();
|
||||
jest.spyOn(userRepo, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(authService.refreshToken({ id: userId })).rejects.toThrow(
|
||||
UserNotFoundException,
|
||||
);
|
||||
|
||||
expect(MockJwtService.sign).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateApiKey', () => {
|
||||
it('validating an api key succeeds with a valid api key', async () => {
|
||||
const apiKey = faker.string.uuid();
|
||||
const projectId = faker.number.int();
|
||||
|
||||
const result = await authService.validateApiKey(apiKey, projectId);
|
||||
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
it('validating an api key succeeds with an invalid api key', async () => {
|
||||
const apiKey = faker.string.uuid();
|
||||
const projectId = faker.number.int();
|
||||
jest.spyOn(apiKeyRepo, 'find').mockResolvedValue([] as ApiKeyEntity[]);
|
||||
|
||||
const result = await authService.validateApiKey(apiKey, projectId);
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOAuthLoginURL', () => {
|
||||
it('getting an oauth login url succeeds with oauth using tenant', async () => {
|
||||
const clientId = faker.string.sample();
|
||||
const scopeString = faker.string.sample();
|
||||
const authCodeRequestURL = faker.internet.domainName();
|
||||
tenantRepo.setUseOAuth(true, {
|
||||
clientId,
|
||||
scopeString,
|
||||
authCodeRequestURL,
|
||||
});
|
||||
|
||||
const OAuthLoginURL = await authService.getOAuthLoginURL();
|
||||
|
||||
expect(OAuthLoginURL.includes(authCodeRequestURL));
|
||||
expect(OAuthLoginURL.includes(`client_id=${clientId}`));
|
||||
expect(OAuthLoginURL.includes(`scope=${scopeString}`));
|
||||
});
|
||||
it('getting an oauth login url fails with no oauth using tenant', async () => {
|
||||
tenantRepo.setUseOAuth(false, null);
|
||||
|
||||
await expect(authService.getOAuthLoginURL()).rejects.toThrow(
|
||||
new BadRequestException('OAuth login is disabled.'),
|
||||
);
|
||||
});
|
||||
it('getting an oauth login url fails with no oauthconfig tenant', async () => {
|
||||
tenantRepo.setUseOAuth(true, null);
|
||||
|
||||
await expect(authService.getOAuthLoginURL()).rejects.toThrow(
|
||||
new BadRequestException('OAuth Config is required.'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signInByOAuth', () => {
|
||||
it('signing in by OAuth fails when OAuth is disabled', async () => {
|
||||
const code = faker.string.alphanumeric(32);
|
||||
tenantRepo.setUseOAuth(false, null);
|
||||
|
||||
await expect(authService.signInByOAuth(code)).rejects.toThrow(
|
||||
new BadRequestException('OAuth login is disabled.'),
|
||||
);
|
||||
});
|
||||
|
||||
it('signing in by OAuth fails with no OAuth config', async () => {
|
||||
const code = faker.string.alphanumeric(32);
|
||||
tenantRepo.setUseOAuth(true, null);
|
||||
|
||||
await expect(authService.signInByOAuth(code)).rejects.toThrow(
|
||||
new BadRequestException('OAuth Config is required.'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import crypto from 'crypto';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { StringValue } from 'ms';
|
||||
import { catchError, lastValueFrom, map } from 'rxjs';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
|
||||
import { EmailVerificationMailingService } from '@/shared/mailing/email-verification-mailing.service';
|
||||
import { NotVerifiedEmailException } from '@/shared/mailing/exceptions';
|
||||
|
||||
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||
import { CodeTypeEnum } from '../../../shared/code/code-type.enum';
|
||||
import { CodeService } from '../../../shared/code/code.service';
|
||||
import { ApiKeyService } from '../project/api-key/api-key.service';
|
||||
import { MemberService } from '../project/member/member.service';
|
||||
import { RoleService } from '../project/role/role.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import { CreateUserService } from '../user/create-user.service';
|
||||
import { UserDto } from '../user/dtos';
|
||||
import { SignUpMethodEnum, UserStateEnum } from '../user/entities/enums';
|
||||
import {
|
||||
UserAlreadyExistsException,
|
||||
UserNotFoundException,
|
||||
} from '../user/exceptions';
|
||||
import { UserService } from '../user/user.service';
|
||||
import type {
|
||||
JwtDto,
|
||||
SendEmailCodeDto,
|
||||
SignUpInvitationUserDto,
|
||||
ValidateEmailUserDto,
|
||||
VerifyEmailCodeDto,
|
||||
} from './dtos';
|
||||
import { SignUpEmailUserDto, SignUpOauthUserDto } from './dtos';
|
||||
import { PasswordNotMatchException, UserBlockedException } from './exceptions';
|
||||
|
||||
interface AccessTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
type UserProfileResponse = Record<string, string>;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly createUserService: CreateUserService,
|
||||
private readonly userService: UserService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly emailVerificationMailingService: EmailVerificationMailingService,
|
||||
private readonly codeService: CodeService,
|
||||
private readonly apiKeyService: ApiKeyService,
|
||||
private readonly tenantService: TenantService,
|
||||
private readonly roleService: RoleService,
|
||||
private readonly memberService: MemberService,
|
||||
private readonly configService: ConfigService<ConfigServiceType>,
|
||||
private readonly httpService: HttpService,
|
||||
) {}
|
||||
|
||||
async sendEmailCode({ email }: SendEmailCodeDto) {
|
||||
const user = await this.userService.findByEmailAndSignUpMethod(
|
||||
email,
|
||||
SignUpMethodEnum.EMAIL,
|
||||
);
|
||||
if (user) throw new UserAlreadyExistsException();
|
||||
await this.memberService.validateEmail(email);
|
||||
|
||||
const code = await this.codeService.setCode({
|
||||
type: CodeTypeEnum.EMAIL_VEIRIFICATION,
|
||||
key: email,
|
||||
});
|
||||
|
||||
// Skip email sending in test environment
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
this.logger.warn(
|
||||
`Skipping email sending for code: ${code}, email: ${email}`,
|
||||
);
|
||||
} else {
|
||||
await this.emailVerificationMailingService.send({ code, email });
|
||||
}
|
||||
|
||||
return DateTime.utc()
|
||||
.plus({ seconds: 5 * 60 })
|
||||
.toISO();
|
||||
}
|
||||
|
||||
async verifyEmailCode({ code, email }: VerifyEmailCodeDto) {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
const { error } = await this.codeService.verifyCode({
|
||||
type: CodeTypeEnum.EMAIL_VEIRIFICATION,
|
||||
key: email,
|
||||
code,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async validateEmailUser({ email, password }: ValidateEmailUserDto) {
|
||||
const user = await this.userService.findByEmailAndSignUpMethod(
|
||||
email,
|
||||
SignUpMethodEnum.EMAIL,
|
||||
);
|
||||
if (!user) throw new UserNotFoundException();
|
||||
if (!bcrypt.compareSync(password, user.hashPassword)) {
|
||||
throw new PasswordNotMatchException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async signUpEmailUser(dto: SignUpEmailUserDto) {
|
||||
let isVerified: boolean;
|
||||
try {
|
||||
isVerified = await this.codeService.checkVerified(
|
||||
CodeTypeEnum.EMAIL_VEIRIFICATION,
|
||||
dto.email,
|
||||
);
|
||||
} catch {
|
||||
throw new BadRequestException('must request email verification');
|
||||
}
|
||||
if (!isVerified) throw new NotVerifiedEmailException();
|
||||
|
||||
return await this.createUserService.createEmailUser(dto);
|
||||
}
|
||||
|
||||
async signUpInvitationUser(dto: SignUpInvitationUserDto) {
|
||||
const { code, ...rest } = dto;
|
||||
|
||||
const { error } = await this.codeService.verifyCode({
|
||||
type: CodeTypeEnum.USER_INVITATION,
|
||||
key: dto.email,
|
||||
code,
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
const data = await this.codeService.getDataByCodeAndType(
|
||||
CodeTypeEnum.USER_INVITATION,
|
||||
code,
|
||||
);
|
||||
|
||||
return await this.createUserService.createInvitationUser({
|
||||
...rest,
|
||||
type: data.userType,
|
||||
roleId: data.roleId,
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async signUpOAuthUser(dto: SignUpOauthUserDto) {
|
||||
const { email, projectName, roleName } = dto;
|
||||
|
||||
const user = await this.createUserService.createOAuthUser({ email });
|
||||
if (!projectName || !roleName) return;
|
||||
const role = await this.roleService.findByProjectNameAndRoleName(
|
||||
projectName,
|
||||
roleName,
|
||||
);
|
||||
|
||||
await this.memberService.create({ roleId: role.id, userId: user.id });
|
||||
}
|
||||
|
||||
async signIn(user: UserDto): Promise<JwtDto> {
|
||||
const { email, id, department, name, type } = user;
|
||||
|
||||
const { allowDomains } = await this.tenantService.findOne();
|
||||
|
||||
if (email && allowDomains && allowDomains.length > 0) {
|
||||
const domain = email.substring(email.lastIndexOf('@') + 1);
|
||||
if (!allowDomains.includes(domain)) {
|
||||
throw new BadRequestException('Signed in with invalid domain.');
|
||||
}
|
||||
}
|
||||
|
||||
const { state } = await this.userService.findById(id);
|
||||
|
||||
if (state === UserStateEnum.Blocked) throw new UserBlockedException();
|
||||
const { accessTokenExpiredTime, refreshTokenExpiredTime } =
|
||||
this.configService.get('jwt', { infer: true }) ?? {};
|
||||
|
||||
return {
|
||||
accessToken: this.jwtService.sign(
|
||||
{ sub: id, email, department, name, type },
|
||||
{
|
||||
expiresIn: (accessTokenExpiredTime ?? '10m') as StringValue | number,
|
||||
},
|
||||
),
|
||||
refreshToken: this.jwtService.sign(
|
||||
{ sub: id, email },
|
||||
{
|
||||
expiresIn: (refreshTokenExpiredTime ?? '1h') as StringValue | number,
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken({ id }: { id: number }): Promise<JwtDto> {
|
||||
const user = await this.userService.findById(id);
|
||||
return this.signIn(UserDto.transform(user));
|
||||
}
|
||||
|
||||
async validateApiKey(value: string, projectId: number) {
|
||||
const apiKeys = await this.apiKeyService.findByProjectIdAndValue(
|
||||
projectId,
|
||||
value,
|
||||
);
|
||||
|
||||
if (apiKeys.length === 1) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
async getOAuthLoginURL(callback_url?: string) {
|
||||
const { useOAuth, oauthConfig } = await this.tenantService.findOne();
|
||||
|
||||
if (!useOAuth) {
|
||||
throw new BadRequestException('OAuth login is disabled.');
|
||||
}
|
||||
if (!oauthConfig) {
|
||||
throw new BadRequestException('OAuth Config is required.');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
redirect_uri: this.getRedirectURI(),
|
||||
client_id: oauthConfig.clientId,
|
||||
response_type: 'code',
|
||||
state: crypto.randomBytes(10).toString('hex'),
|
||||
scope: oauthConfig.scopeString,
|
||||
callback_url: encodeURIComponent(callback_url ?? ''),
|
||||
});
|
||||
|
||||
return `${oauthConfig.authCodeRequestURL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
private async getAccessToken(code: string): Promise<string> {
|
||||
const { oauthConfig, useOAuth } = await this.tenantService.findOne();
|
||||
|
||||
if (!useOAuth) {
|
||||
throw new BadRequestException('OAuth login is disabled.');
|
||||
}
|
||||
if (!oauthConfig) {
|
||||
throw new BadRequestException('OAuth Config is required.');
|
||||
}
|
||||
|
||||
const { accessTokenRequestURL, clientId, clientSecret } = oauthConfig;
|
||||
return await lastValueFrom<string>(
|
||||
this.httpService
|
||||
.post<AccessTokenResponse>(
|
||||
accessTokenRequestURL,
|
||||
{
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: this.getRedirectURI(),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(
|
||||
clientId + ':' + clientSecret,
|
||||
).toString('base64')}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
},
|
||||
)
|
||||
.pipe<string>(
|
||||
map<AxiosResponse<AccessTokenResponse, any>, string>(
|
||||
(res) => res.data.access_token,
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
catchError((error: Error) => {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new InternalServerErrorException({
|
||||
axiosError: {
|
||||
...error.response?.data,
|
||||
status: error.response?.status,
|
||||
} as object,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async getEmailByAccessToken(accessToken: string): Promise<string> {
|
||||
const { oauthConfig } = await this.tenantService.findOne();
|
||||
|
||||
if (!oauthConfig) {
|
||||
throw new BadRequestException('OAuth Config is required.');
|
||||
}
|
||||
return await lastValueFrom<string>(
|
||||
this.httpService
|
||||
.get<UserProfileResponse>(oauthConfig.userProfileRequestURL, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
.pipe(map((res) => res.data[oauthConfig.emailKey]))
|
||||
.pipe(
|
||||
catchError((error: Error) => {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new InternalServerErrorException({
|
||||
axiosError: {
|
||||
...error.response?.data,
|
||||
status: error.response?.status,
|
||||
} as object,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async signInByOAuth(code: string) {
|
||||
const accessToken = await this.getAccessToken(code);
|
||||
|
||||
const email = await this.getEmailByAccessToken(accessToken);
|
||||
|
||||
const user = await this.userService.findByEmailAndSignUpMethod(
|
||||
email,
|
||||
SignUpMethodEnum.OAUTH,
|
||||
);
|
||||
if (user) {
|
||||
return await this.signIn(user);
|
||||
} else {
|
||||
const user = await this.createUserService.createOAuthUser({ email });
|
||||
return await this.signIn(user);
|
||||
}
|
||||
}
|
||||
|
||||
private getRedirectURI() {
|
||||
const app = this.configService.get('app', { infer: true });
|
||||
|
||||
return `${app?.adminWebUrl}/auth/oauth-callback`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { SendEmailCodeDto } from './send-email-code.dto';
|
||||
export { VerifyEmailCodeDto } from './verify-email-code.dto';
|
||||
export { ValidateEmailUserDto } from './validate-email-user.dto';
|
||||
export { SignUpInvitationUserDto } from './sign-up-invitation-user.dto';
|
||||
export { SignUpEmailUserDto } from './sign-up-email-user.dto';
|
||||
export { SignUpOauthUserDto } from './sign-up-oauth-user.dto';
|
||||
export { JwtDto } from './jwt.dto';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class JwtDto {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
import { IsPassword } from '@/common/decorators/is-password';
|
||||
|
||||
export class EmailUserSignInRequestDto {
|
||||
@ApiProperty({ nullable: true, type: String })
|
||||
@IsEmail()
|
||||
email: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
@IsPassword()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
import { IsPassword } from '@/common/decorators/is-password';
|
||||
|
||||
export class EmailUserSignUpRequestDto {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsPassword()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
export class EmailVerificationCodeRequestDto {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
code: string;
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail } from 'class-validator';
|
||||
|
||||
export class EmailVerificationMailingRequestDto {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { EmailUserSignInRequestDto } from './email-user-sign-in-request.dto';
|
||||
export { EmailUserSignUpRequestDto } from './email-user-sign-up-request.dto';
|
||||
export { EmailVerificationMailingRequestDto } from './email-verification-mailing-request.dto';
|
||||
export { EmailVerificationCodeRequestDto } from './email-verification-code-request.dto';
|
||||
export { InvitationUserSignUpRequestDto } from './invitation-user-sign-up-request.dto';
|
||||
export { OAuthUserSignUpRequestDto } from './oauth-user-sign-up-request.dto';
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
import { IsPassword } from '@/common/decorators/is-password';
|
||||
|
||||
export class InvitationUserSignUpRequestDto {
|
||||
@ApiProperty()
|
||||
@IsPassword()
|
||||
password: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class OAuthUserSignUpRequestDto {
|
||||
@ApiProperty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
projectName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
roleName: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { SendEmailCodeResponseDto } from './send-email-code-response.dto';
|
||||
export { SignInResponseDto } from './sign-in-response.dto';
|
||||
export { OAuthLoginUrlResponseDto } from './oauth-login-url.response.dto';
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class OAuthLoginUrlResponseDto {
|
||||
@ApiProperty()
|
||||
url: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, plainToInstance } from 'class-transformer';
|
||||
|
||||
export class SendEmailCodeResponseDto {
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
expiredAt: string;
|
||||
|
||||
public static transform(params: any): SendEmailCodeResponseDto {
|
||||
return plainToInstance(SendEmailCodeResponseDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class SignInResponseDto {
|
||||
@ApiProperty()
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty()
|
||||
refreshToken?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class SendEmailCodeDto {
|
||||
email: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class SignUpEmailUserDto {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export class SignUpInvitationUserDto {
|
||||
password: string;
|
||||
code: string;
|
||||
email: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class SignUpOauthUserDto {
|
||||
email: string;
|
||||
projectName: string;
|
||||
roleName: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class ValidateEmailUserDto {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class VerifyEmailCodeDto {
|
||||
code: string;
|
||||
email: string;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { PasswordNotMatchException } from './password-not.match.exception';
|
||||
export { UserBlockedException } from './user-blocked.exception';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class PasswordNotMatchException extends NotFoundException {
|
||||
constructor() {
|
||||
super({
|
||||
code: ErrorCode.Auth.PasswordNotMatch,
|
||||
message: 'Password is not matched',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class UserBlockedException extends UnauthorizedException {
|
||||
constructor() {
|
||||
super({
|
||||
code: ErrorCode.Auth.BlockedUser,
|
||||
message: 'This user is blocked',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class ApiKeyAuthGuard implements CanActivate {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const apiKey = request.headers['x-api-key'] as string | undefined;
|
||||
if (!apiKey) return false;
|
||||
if (apiKey === process.env.MASTER_API_KEY) return true;
|
||||
const projectId = parseInt(request.params.projectId);
|
||||
|
||||
return this.authService.validateApiKey(apiKey, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { JwtAuthGuard } from './jwt-auth.guard';
|
||||
export { ApiKeyAuthGuard } from './api-key.guard';
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ExecutionContext } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
import type { ClsServiceType } from '@/types/cls-service.type';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
}
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly clsService: ClsService<ClsServiceType>) {
|
||||
super();
|
||||
}
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
return super.canActivate(context);
|
||||
}
|
||||
handleRequest(err: any, user: any): any {
|
||||
// You can throw an exception based on either "info" or "err" arguments
|
||||
if (err || !user) {
|
||||
throw err ?? new UnauthorizedException('Invalid jwt');
|
||||
}
|
||||
|
||||
this.clsService.set('userId', (user as User).id);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { CanActivate } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class UseEmailGuard implements CanActivate {
|
||||
constructor(private readonly tenantService: TenantService) {}
|
||||
async canActivate(): Promise<boolean> {
|
||||
const { useEmail } = await this.tenantService.findOne();
|
||||
return useEmail;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { CanActivate } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class UseOAuthGuard implements CanActivate {
|
||||
constructor(private readonly tenantService: TenantService) {}
|
||||
async canActivate(): Promise<boolean> {
|
||||
const { useOAuth } = await this.tenantService.findOne();
|
||||
return useOAuth;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, JwtFromRequestFunction, Strategy } from 'passport-jwt';
|
||||
|
||||
import type { UserTypeEnum } from '@/domains/admin/user/entities/enums';
|
||||
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||
|
||||
interface IPayload {
|
||||
sub: string;
|
||||
email: string;
|
||||
type: UserTypeEnum;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
interface StrategyOptions {
|
||||
jwtFromRequest: JwtFromRequestFunction;
|
||||
ignoreExpiration: boolean;
|
||||
secretOrKey: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService<ConfigServiceType>) {
|
||||
const { secret } = configService.get('jwt', { infer: true }) ?? {};
|
||||
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: secret,
|
||||
} as StrategyOptions);
|
||||
}
|
||||
|
||||
validate(payload: IPayload) {
|
||||
const { email, sub, type } = payload;
|
||||
return { id: sub, email, type };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
|
||||
import { UserDto } from '@/domains/admin/user/dtos';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private authService: AuthService) {
|
||||
super({ usernameField: 'email' });
|
||||
}
|
||||
|
||||
async validate(email: string, password: string) {
|
||||
const user = await this.authService.validateEmailUser({ email, password });
|
||||
return UserDto.transform(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { getMockProvider, MockDataSource } from '@/test-utils/util-functions';
|
||||
import { ChannelController } from './channel.controller';
|
||||
import { ChannelService } from './channel.service';
|
||||
import {
|
||||
CreateChannelRequestDto,
|
||||
FindChannelsByProjectIdRequestDto,
|
||||
ImageUploadUrlTestRequestDto,
|
||||
UpdateChannelFieldsRequestDto,
|
||||
UpdateChannelRequestDto,
|
||||
} from './dtos/requests';
|
||||
import {
|
||||
CreateChannelResponseDto,
|
||||
FindChannelByIdResponseDto,
|
||||
FindChannelsByProjectIdResponseDto,
|
||||
} from './dtos/responses';
|
||||
|
||||
const MockChannelService = {
|
||||
create: jest.fn(),
|
||||
findAllByProjectId: jest.fn(),
|
||||
deleteById: jest.fn(),
|
||||
checkName: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
updateInfo: jest.fn(),
|
||||
updateFields: jest.fn(),
|
||||
isValidImageConfig: jest.fn(),
|
||||
createImageDownloadUrl: jest.fn(),
|
||||
};
|
||||
|
||||
describe('ChannelController', () => {
|
||||
let channelController: ChannelController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [ChannelController],
|
||||
providers: [
|
||||
getMockProvider(ChannelService, MockChannelService),
|
||||
getMockProvider(DataSource, MockDataSource),
|
||||
],
|
||||
}).compile();
|
||||
|
||||
channelController = module.get<ChannelController>(ChannelController);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create channel successfully', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const dto = new CreateChannelRequestDto();
|
||||
dto.name = faker.string.sample();
|
||||
dto.description = faker.string.sample();
|
||||
dto.feedbackSearchMaxDays = faker.number.int();
|
||||
dto.fields = [];
|
||||
|
||||
const mockChannel = { id: faker.number.int(), name: dto.name };
|
||||
MockChannelService.create.mockResolvedValue(mockChannel);
|
||||
|
||||
const result = await channelController.create(projectId, dto);
|
||||
|
||||
expect(MockChannelService.create).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
feedbackSearchMaxDays: dto.feedbackSearchMaxDays,
|
||||
fields: dto.fields,
|
||||
}),
|
||||
);
|
||||
expect(result).toBeInstanceOf(CreateChannelResponseDto);
|
||||
expect(result.id).toBe(mockChannel.id);
|
||||
});
|
||||
|
||||
it('should handle channel creation failure', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const dto = new CreateChannelRequestDto();
|
||||
dto.name = faker.string.sample();
|
||||
dto.description = faker.string.sample();
|
||||
dto.feedbackSearchMaxDays = faker.number.int();
|
||||
dto.fields = [];
|
||||
|
||||
const error = new BadRequestException('Channel creation failed');
|
||||
MockChannelService.create.mockRejectedValue(error);
|
||||
|
||||
await expect(channelController.create(projectId, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(MockChannelService.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
describe('findAllByProjectId', () => {
|
||||
it('should return channels by project id successfully', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const dto = new FindChannelsByProjectIdRequestDto();
|
||||
dto.limit = faker.number.int({ min: 1, max: 100 });
|
||||
dto.page = faker.number.int({ min: 1, max: 10 });
|
||||
dto.searchText = faker.string.sample();
|
||||
|
||||
const mockChannels = {
|
||||
items: [
|
||||
{ id: faker.number.int(), name: faker.string.sample() },
|
||||
{ id: faker.number.int(), name: faker.string.sample() },
|
||||
],
|
||||
meta: {
|
||||
itemCount: 2,
|
||||
totalItems: 2,
|
||||
itemsPerPage: dto.limit,
|
||||
totalPages: 1,
|
||||
currentPage: dto.page,
|
||||
},
|
||||
};
|
||||
MockChannelService.findAllByProjectId.mockResolvedValue(mockChannels);
|
||||
|
||||
const result = await channelController.findAllByProjectId(projectId, dto);
|
||||
|
||||
expect(MockChannelService.findAllByProjectId).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.findAllByProjectId).toHaveBeenCalledWith({
|
||||
options: { limit: dto.limit, page: dto.page },
|
||||
searchText: dto.searchText,
|
||||
projectId,
|
||||
});
|
||||
expect(result).toBeInstanceOf(FindChannelsByProjectIdResponseDto);
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.meta.totalItems).toBe(2);
|
||||
});
|
||||
|
||||
it('should return empty channels when no data found', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const dto = new FindChannelsByProjectIdRequestDto();
|
||||
dto.limit = faker.number.int({ min: 1, max: 100 });
|
||||
dto.page = faker.number.int({ min: 1, max: 10 });
|
||||
dto.searchText = 'nonexistent';
|
||||
|
||||
const mockChannels = {
|
||||
items: [],
|
||||
meta: {
|
||||
itemCount: 0,
|
||||
totalItems: 0,
|
||||
itemsPerPage: dto.limit,
|
||||
totalPages: 0,
|
||||
currentPage: dto.page,
|
||||
},
|
||||
};
|
||||
MockChannelService.findAllByProjectId.mockResolvedValue(mockChannels);
|
||||
|
||||
const result = await channelController.findAllByProjectId(projectId, dto);
|
||||
|
||||
expect(MockChannelService.findAllByProjectId).toHaveBeenCalledTimes(1);
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.meta.totalItems).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle service error', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const dto = new FindChannelsByProjectIdRequestDto();
|
||||
dto.limit = faker.number.int({ min: 1, max: 100 });
|
||||
dto.page = faker.number.int({ min: 1, max: 10 });
|
||||
|
||||
const error = new BadRequestException('Service error');
|
||||
MockChannelService.findAllByProjectId.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
channelController.findAllByProjectId(projectId, dto),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(MockChannelService.findAllByProjectId).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
describe('delete', () => {
|
||||
it('should delete channel successfully', async () => {
|
||||
const channelId = faker.number.int();
|
||||
MockChannelService.deleteById.mockResolvedValue(undefined);
|
||||
|
||||
await channelController.delete(channelId);
|
||||
|
||||
expect(MockChannelService.deleteById).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.deleteById).toHaveBeenCalledWith(channelId);
|
||||
});
|
||||
|
||||
it('should handle channel deletion failure', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const error = new BadRequestException('Channel not found');
|
||||
MockChannelService.deleteById.mockRejectedValue(error);
|
||||
|
||||
await expect(channelController.delete(channelId)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(MockChannelService.deleteById).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.deleteById).toHaveBeenCalledWith(channelId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkName', () => {
|
||||
it('should check channel name availability successfully', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const name = faker.string.sample();
|
||||
MockChannelService.checkName.mockResolvedValue(true);
|
||||
|
||||
const result = await channelController.checkName(projectId, name);
|
||||
|
||||
expect(MockChannelService.checkName).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.checkName).toHaveBeenCalledWith({
|
||||
projectId,
|
||||
name,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when name is not available', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const name = faker.string.sample();
|
||||
MockChannelService.checkName.mockResolvedValue(false);
|
||||
|
||||
const result = await channelController.checkName(projectId, name);
|
||||
|
||||
expect(MockChannelService.checkName).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should find channel by id successfully', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const mockChannel = {
|
||||
id: channelId,
|
||||
name: faker.string.sample(),
|
||||
description: faker.string.sample(),
|
||||
fields: [],
|
||||
};
|
||||
MockChannelService.findById.mockResolvedValue(mockChannel);
|
||||
|
||||
const result = await channelController.findOne(channelId);
|
||||
|
||||
expect(MockChannelService.findById).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.findById).toHaveBeenCalledWith({ channelId });
|
||||
expect(result).toBeInstanceOf(FindChannelByIdResponseDto);
|
||||
expect(result.id).toBe(channelId);
|
||||
});
|
||||
|
||||
it('should handle channel not found', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const error = new BadRequestException('Channel not found');
|
||||
MockChannelService.findById.mockRejectedValue(error);
|
||||
|
||||
await expect(channelController.findOne(channelId)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(MockChannelService.findById).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOne', () => {
|
||||
it('should update channel successfully', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new UpdateChannelRequestDto();
|
||||
dto.name = faker.string.sample();
|
||||
dto.description = faker.string.sample();
|
||||
MockChannelService.updateInfo.mockResolvedValue(undefined);
|
||||
|
||||
await channelController.updateOne(channelId, dto);
|
||||
|
||||
expect(MockChannelService.updateInfo).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.updateInfo).toHaveBeenCalledWith(
|
||||
channelId,
|
||||
dto,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle update failure', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new UpdateChannelRequestDto();
|
||||
const error = new BadRequestException('Update failed');
|
||||
MockChannelService.updateInfo.mockRejectedValue(error);
|
||||
|
||||
await expect(channelController.updateOne(channelId, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(MockChannelService.updateInfo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFields', () => {
|
||||
it('should update channel fields successfully', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new UpdateChannelFieldsRequestDto();
|
||||
dto.fields = [];
|
||||
MockChannelService.updateFields.mockResolvedValue(undefined);
|
||||
|
||||
await channelController.updateFields(channelId, dto);
|
||||
|
||||
expect(MockChannelService.updateFields).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.updateFields).toHaveBeenCalledWith(
|
||||
channelId,
|
||||
dto,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle fields update failure', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new UpdateChannelFieldsRequestDto();
|
||||
const error = new BadRequestException('Fields update failed');
|
||||
MockChannelService.updateFields.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
channelController.updateFields(channelId, dto),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(MockChannelService.updateFields).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImageUploadUrlTest', () => {
|
||||
it('should test image upload URL successfully', async () => {
|
||||
const dto = new ImageUploadUrlTestRequestDto();
|
||||
dto.accessKeyId = faker.string.sample();
|
||||
dto.secretAccessKey = faker.string.sample();
|
||||
dto.endpoint = faker.internet.url();
|
||||
dto.region = faker.string.sample();
|
||||
dto.bucket = faker.string.sample();
|
||||
MockChannelService.isValidImageConfig.mockResolvedValue(true);
|
||||
|
||||
const result = await channelController.getImageUploadUrlTest(dto);
|
||||
|
||||
expect(MockChannelService.isValidImageConfig).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.isValidImageConfig).toHaveBeenCalledWith({
|
||||
accessKeyId: dto.accessKeyId,
|
||||
secretAccessKey: dto.secretAccessKey,
|
||||
endpoint: dto.endpoint,
|
||||
region: dto.region,
|
||||
bucket: dto.bucket,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('should return false when image config is invalid', async () => {
|
||||
const dto = new ImageUploadUrlTestRequestDto();
|
||||
MockChannelService.isValidImageConfig.mockResolvedValue(false);
|
||||
|
||||
const result = await channelController.getImageUploadUrlTest(dto);
|
||||
|
||||
expect(result).toEqual({ success: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getImageDownloadUrl', () => {
|
||||
it('should get image download URL successfully', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const channelId = faker.number.int();
|
||||
const imageKey = faker.string.sample();
|
||||
const mockChannel = {
|
||||
id: channelId,
|
||||
project: { id: projectId },
|
||||
imageConfig: {
|
||||
accessKeyId: faker.string.sample(),
|
||||
secretAccessKey: faker.string.sample(),
|
||||
endpoint: faker.internet.url(),
|
||||
region: faker.string.sample(),
|
||||
bucket: faker.string.sample(),
|
||||
},
|
||||
};
|
||||
const mockUrl = faker.internet.url();
|
||||
MockChannelService.findById.mockResolvedValue(mockChannel);
|
||||
MockChannelService.createImageDownloadUrl.mockResolvedValue(mockUrl);
|
||||
|
||||
const result = await channelController.getImageDownloadUrl(
|
||||
projectId,
|
||||
channelId,
|
||||
imageKey,
|
||||
);
|
||||
|
||||
expect(MockChannelService.findById).toHaveBeenCalledTimes(1);
|
||||
expect(MockChannelService.createImageDownloadUrl).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(result).toBe(mockUrl);
|
||||
});
|
||||
|
||||
it('should throw error when imageKey is missing', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const channelId = faker.number.int();
|
||||
|
||||
await expect(
|
||||
channelController.getImageDownloadUrl(projectId, channelId, ''),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw error when channel project id mismatch', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const channelId = faker.number.int();
|
||||
const imageKey = faker.string.sample();
|
||||
const mockChannel = {
|
||||
id: channelId,
|
||||
project: { id: faker.number.int() }, // Different project ID
|
||||
};
|
||||
MockChannelService.findById.mockResolvedValue(mockChannel);
|
||||
|
||||
await expect(
|
||||
channelController.getImageDownloadUrl(projectId, channelId, imageKey),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw error when channel has no image config', async () => {
|
||||
const projectId = faker.number.int();
|
||||
const channelId = faker.number.int();
|
||||
const imageKey = faker.string.sample();
|
||||
const mockChannel = {
|
||||
id: channelId,
|
||||
project: { id: projectId },
|
||||
imageConfig: null,
|
||||
};
|
||||
MockChannelService.findById.mockResolvedValue(mockChannel);
|
||||
|
||||
await expect(
|
||||
channelController.getImageDownloadUrl(projectId, channelId, imageKey),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { JwtAuthGuard } from '@/domains/admin/auth/guards';
|
||||
import { PermissionEnum } from '@/domains/admin/project/role/permission.enum';
|
||||
import { RequirePermission } from '@/domains/admin/project/role/require-permission.decorator';
|
||||
import { ChannelService } from './channel.service';
|
||||
import { CreateChannelDto } from './dtos';
|
||||
import {
|
||||
CreateChannelRequestDto,
|
||||
FindChannelsByProjectIdRequestDto,
|
||||
ImageUploadUrlTestRequestDto,
|
||||
UpdateChannelFieldsRequestDto,
|
||||
UpdateChannelRequestDto,
|
||||
} from './dtos/requests';
|
||||
import {
|
||||
CreateChannelResponseDto,
|
||||
FindChannelByIdResponseDto,
|
||||
FindChannelsByProjectIdResponseDto,
|
||||
ImageUploadUrlTestResponseDto,
|
||||
} from './dtos/responses';
|
||||
|
||||
@ApiTags('channel')
|
||||
@Controller('/admin/projects/:projectId/channels')
|
||||
@ApiBearerAuth()
|
||||
export class ChannelController {
|
||||
constructor(private readonly channelService: ChannelService) {}
|
||||
|
||||
@RequirePermission(PermissionEnum.channel_create)
|
||||
@ApiCreatedResponse({ type: CreateChannelResponseDto })
|
||||
@Post('/')
|
||||
async create(
|
||||
@Param('projectId', ParseIntPipe) projectId: number,
|
||||
@Body() body: CreateChannelRequestDto,
|
||||
) {
|
||||
return CreateChannelResponseDto.transform(
|
||||
await this.channelService.create(
|
||||
CreateChannelDto.from({ ...body, projectId }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOkResponse({ type: FindChannelsByProjectIdResponseDto })
|
||||
@Get('/')
|
||||
async findAllByProjectId(
|
||||
@Param('projectId', ParseIntPipe) projectId: number,
|
||||
@Query() query: FindChannelsByProjectIdRequestDto,
|
||||
) {
|
||||
const { searchText, limit, page } = query;
|
||||
return FindChannelsByProjectIdResponseDto.transform(
|
||||
await this.channelService.findAllByProjectId({
|
||||
options: { limit, page },
|
||||
searchText,
|
||||
projectId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('/name-check')
|
||||
@ApiOkResponse({ type: Boolean })
|
||||
async checkName(
|
||||
@Param('projectId', ParseIntPipe) projectId: number,
|
||||
@Query('name') name: string,
|
||||
) {
|
||||
return this.channelService.checkName({ projectId, name });
|
||||
}
|
||||
|
||||
@ApiParam({ name: 'projectId', type: Number })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOkResponse({ type: FindChannelByIdResponseDto })
|
||||
@Get('/:channelId')
|
||||
async findOne(@Param('channelId', ParseIntPipe) channelId: number) {
|
||||
return FindChannelByIdResponseDto.transform(
|
||||
await this.channelService.findById({ channelId }),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiParam({ name: 'projectId', type: Number })
|
||||
@RequirePermission(PermissionEnum.channel_update)
|
||||
@Put('/:channelId')
|
||||
async updateOne(
|
||||
@Param('channelId', ParseIntPipe) channelId: number,
|
||||
@Body() body: UpdateChannelRequestDto,
|
||||
) {
|
||||
await this.channelService.updateInfo(channelId, body);
|
||||
}
|
||||
|
||||
@ApiParam({ name: 'projectId', type: Number })
|
||||
@RequirePermission(PermissionEnum.channel_field_update)
|
||||
@Put('/:channelId/fields')
|
||||
async updateFields(
|
||||
@Param('channelId', ParseIntPipe) channelId: number,
|
||||
@Body() body: UpdateChannelFieldsRequestDto,
|
||||
) {
|
||||
await this.channelService.updateFields(channelId, body);
|
||||
}
|
||||
|
||||
@ApiParam({ name: 'projectId', type: Number })
|
||||
@RequirePermission(PermissionEnum.channel_delete)
|
||||
@Delete('/:channelId')
|
||||
async delete(@Param('channelId', ParseIntPipe) channelId: number) {
|
||||
await this.channelService.deleteById(channelId);
|
||||
}
|
||||
|
||||
@ApiParam({ name: 'projectId', type: Number })
|
||||
@ApiOkResponse({ type: ImageUploadUrlTestResponseDto })
|
||||
@Post('/image-upload-url-test')
|
||||
async getImageUploadUrlTest(
|
||||
@Body()
|
||||
{
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
}: ImageUploadUrlTestRequestDto,
|
||||
) {
|
||||
return {
|
||||
success: await this.channelService.isValidImageConfig({
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ApiParam({ name: 'projectId', type: Number })
|
||||
@ApiParam({ name: 'channelId', type: Number })
|
||||
@ApiQuery({
|
||||
name: 'imageKey',
|
||||
type: String,
|
||||
required: true,
|
||||
description: 'Image Key for the pre-signed url download',
|
||||
example: 'test-image-key.jpg',
|
||||
})
|
||||
@ApiOkResponse({ type: String })
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('/:channelId/image-download-url')
|
||||
async getImageDownloadUrl(
|
||||
@Param('projectId', ParseIntPipe) projectId: number,
|
||||
@Param('channelId', ParseIntPipe) channelId: number,
|
||||
@Query('imageKey') imageKey: string,
|
||||
) {
|
||||
if (!imageKey) {
|
||||
throw new BadRequestException('imageKey is required in query parameter');
|
||||
}
|
||||
const channel = await this.channelService.findById({ channelId });
|
||||
if (channel.project.id !== projectId) {
|
||||
throw new BadRequestException('Invalid channel id');
|
||||
}
|
||||
if (!channel.imageConfig) {
|
||||
throw new BadRequestException('No image config in this channel');
|
||||
}
|
||||
|
||||
return await this.channelService.createImageDownloadUrl({
|
||||
accessKeyId: channel.imageConfig.accessKeyId,
|
||||
secretAccessKey: channel.imageConfig.secretAccessKey,
|
||||
endpoint: channel.imageConfig.endpoint,
|
||||
region: channel.imageConfig.region,
|
||||
bucket: channel.imageConfig.bucket,
|
||||
imageKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
Relation,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
|
||||
import { CommonEntity } from '@/common/entities';
|
||||
import { FeedbackStatisticsEntity } from '@/domains/admin/statistics/feedback/feedback-statistics.entity';
|
||||
import { FeedbackEntity } from '../../feedback/feedback.entity';
|
||||
import { AIIssueTemplatesEntity } from '../../project/ai/ai-issue-templates.entity';
|
||||
import { ProjectEntity } from '../../project/project/project.entity';
|
||||
import { EventEntity } from '../../project/webhook/event.entity';
|
||||
import { FieldEntity } from '../field/field.entity';
|
||||
|
||||
export interface ImageConfig {
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
domainWhiteList: string[];
|
||||
}
|
||||
|
||||
@Entity('channels')
|
||||
@Index(['name', 'createdAt'])
|
||||
@Unique('project-name-unique', ['name', 'project'])
|
||||
export class ChannelEntity extends CommonEntity {
|
||||
@Column('varchar', { length: 255 })
|
||||
name: string;
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
imageConfig: ImageConfig | null;
|
||||
|
||||
@Column('int', { default: 365 })
|
||||
feedbackSearchMaxDays: number;
|
||||
|
||||
@ManyToOne(() => ProjectEntity, (project) => project.channels, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
project: Relation<ProjectEntity>;
|
||||
|
||||
@OneToMany(() => FieldEntity, (field) => field.channel, {
|
||||
cascade: true,
|
||||
})
|
||||
fields: Relation<FieldEntity>[];
|
||||
|
||||
@OneToMany(() => FeedbackEntity, (feedback) => feedback.channel, {
|
||||
cascade: true,
|
||||
})
|
||||
feedbacks: Relation<FeedbackEntity>[];
|
||||
|
||||
@OneToMany(
|
||||
() => AIIssueTemplatesEntity,
|
||||
(aiIssueTemplates) => aiIssueTemplates.channel,
|
||||
{
|
||||
cascade: true,
|
||||
},
|
||||
)
|
||||
aiIssueTemplates: Relation<AIIssueTemplatesEntity>[];
|
||||
|
||||
@OneToMany(
|
||||
() => FeedbackStatisticsEntity,
|
||||
(feedbackStats) => feedbackStats.channel,
|
||||
{
|
||||
cascade: true,
|
||||
},
|
||||
)
|
||||
feedbackStats: Relation<FeedbackStatisticsEntity>[];
|
||||
|
||||
@ManyToMany(() => EventEntity, (event) => event.channels, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
events: EventEntity[];
|
||||
|
||||
static from(
|
||||
name: string,
|
||||
description: string | null,
|
||||
projectId: number,
|
||||
imageConfig: ImageConfig | null,
|
||||
feedbackSearchMaxDays: number,
|
||||
) {
|
||||
const channel = new ChannelEntity();
|
||||
channel.name = name;
|
||||
if (description) {
|
||||
channel.description = description;
|
||||
}
|
||||
if (imageConfig) {
|
||||
channel.imageConfig = imageConfig;
|
||||
}
|
||||
channel.project = new ProjectEntity();
|
||||
channel.project.id = projectId;
|
||||
channel.feedbackSearchMaxDays = feedbackSearchMaxDays;
|
||||
|
||||
return channel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { FeedbackModule } from '@/domains/admin/feedback/feedback.module';
|
||||
import { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||
import { ProjectModule } from '@/domains/admin/project/project/project.module';
|
||||
import { FieldModule } from '../field/field.module';
|
||||
import { ChannelController } from './channel.controller';
|
||||
import { ChannelEntity } from './channel.entity';
|
||||
import { ChannelMySQLService } from './channel.mysql.service';
|
||||
import { ChannelService } from './channel.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ChannelEntity, ProjectEntity]),
|
||||
FieldModule,
|
||||
forwardRef(() => ProjectModule),
|
||||
forwardRef(() => FeedbackModule),
|
||||
],
|
||||
providers: [ChannelMySQLService, ChannelService, OpensearchRepository],
|
||||
controllers: [ChannelController],
|
||||
exports: [ChannelService, ChannelMySQLService],
|
||||
})
|
||||
export class ChannelModule {}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Like, Not, Repository } from 'typeorm';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
|
||||
import { isSelectFieldFormat } from '@/common/enums';
|
||||
import { paginateHelper } from '@/common/helper/paginate.helper';
|
||||
import { ChannelEntity } from './channel.entity';
|
||||
import type {
|
||||
FindAllChannelsByProjectIdDto,
|
||||
FindByChannelIdDto,
|
||||
FindOneByNameAndProjectIdDto,
|
||||
} from './dtos';
|
||||
import { CreateChannelDto, UpdateChannelDto } from './dtos';
|
||||
import {
|
||||
ChannelAlreadyExistsException,
|
||||
ChannelInvalidNameException,
|
||||
ChannelNotFoundException,
|
||||
} from './exceptions';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelMySQLService {
|
||||
constructor(
|
||||
@InjectRepository(ChannelEntity)
|
||||
private readonly repository: Repository<ChannelEntity>,
|
||||
) {}
|
||||
async findOneBy({ name, projectId }: FindOneByNameAndProjectIdDto) {
|
||||
return await this.repository.findOne({
|
||||
where: { name, project: { id: projectId } },
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async create(dto: CreateChannelDto) {
|
||||
const channel = CreateChannelDto.toChannelEntity(dto);
|
||||
|
||||
const duplicateChannel = await this.repository.findOneBy({
|
||||
name: channel.name,
|
||||
project: {
|
||||
id: dto.projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (duplicateChannel) throw new ChannelAlreadyExistsException();
|
||||
|
||||
const savedChannel = await this.repository.save(channel);
|
||||
|
||||
return savedChannel;
|
||||
}
|
||||
|
||||
async findAllByProjectId(dto: FindAllChannelsByProjectIdDto) {
|
||||
const { options, projectId, searchText = '' } = dto;
|
||||
|
||||
return await paginateHelper(
|
||||
this.repository.createQueryBuilder(),
|
||||
{
|
||||
where: { project: { id: projectId }, name: Like(`%${searchText}%`) },
|
||||
order: { createdAt: 'ASC' },
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
async findById({ channelId }: FindByChannelIdDto) {
|
||||
const channel = await this.repository.findOne({
|
||||
where: { id: channelId },
|
||||
relations: {
|
||||
fields: { options: true, aiFieldTemplate: true },
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
if (!channel) throw new ChannelNotFoundException();
|
||||
|
||||
channel.fields = channel.fields.map((field) => {
|
||||
if (!isSelectFieldFormat(field.format)) {
|
||||
delete field.options;
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async update(channelId: number, dto: UpdateChannelDto) {
|
||||
const { name, description, imageConfig, feedbackSearchMaxDays } = dto;
|
||||
const channel = await this.findById({ channelId });
|
||||
|
||||
if (
|
||||
await this.repository.findOne({
|
||||
where: {
|
||||
name,
|
||||
id: Not(channelId),
|
||||
project: { id: channel.project.id },
|
||||
},
|
||||
select: ['id'],
|
||||
})
|
||||
) {
|
||||
throw new ChannelInvalidNameException('Duplicate name');
|
||||
}
|
||||
|
||||
channel.name = name;
|
||||
channel.description = description;
|
||||
channel.imageConfig = imageConfig;
|
||||
channel.feedbackSearchMaxDays = feedbackSearchMaxDays;
|
||||
return await this.repository.save(channel);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async delete(channelId: number) {
|
||||
const channel = new ChannelEntity();
|
||||
channel.id = channelId;
|
||||
return await this.repository.remove(channel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { channelFixture, createFieldDto } from '@/test-utils/fixtures';
|
||||
import { TestConfig } from '@/test-utils/util-functions';
|
||||
import { ChannelServiceProviders } from '../../../../test-utils/providers/channel.service.providers';
|
||||
import { FieldEntity } from '../field/field.entity';
|
||||
import { ChannelEntity } from './channel.entity';
|
||||
import { ChannelService } from './channel.service';
|
||||
import {
|
||||
CreateChannelDto,
|
||||
FindAllChannelsByProjectIdDto,
|
||||
FindByChannelIdDto,
|
||||
FindOneByNameAndProjectIdDto,
|
||||
UpdateChannelDto,
|
||||
UpdateChannelFieldsDto,
|
||||
} from './dtos';
|
||||
import {
|
||||
ChannelAlreadyExistsException,
|
||||
ChannelInvalidNameException,
|
||||
ChannelNotFoundException,
|
||||
} from './exceptions';
|
||||
|
||||
describe('ChannelService', () => {
|
||||
let channelService: ChannelService;
|
||||
let channelRepo: Repository<ChannelEntity>;
|
||||
let fieldRepo: Repository<FieldEntity>;
|
||||
let channelServiceAny: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [TestConfig],
|
||||
providers: ChannelServiceProviders,
|
||||
}).compile();
|
||||
|
||||
channelService = module.get<ChannelService>(ChannelService);
|
||||
channelRepo = module.get(getRepositoryToken(ChannelEntity));
|
||||
fieldRepo = module.get(getRepositoryToken(FieldEntity));
|
||||
channelServiceAny = channelService as any;
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creating a channel succeeds with valid inputs', async () => {
|
||||
const fieldCount = faker.number.int({ min: 1, max: 10 });
|
||||
const dto = new CreateChannelDto();
|
||||
dto.name = channelFixture.name;
|
||||
dto.description = channelFixture.description;
|
||||
dto.projectId = channelFixture.project.id;
|
||||
dto.feedbackSearchMaxDays = channelFixture.feedbackSearchMaxDays;
|
||||
dto.fields = Array.from({ length: fieldCount }).map(createFieldDto);
|
||||
jest.spyOn(channelRepo, 'findOneBy').mockResolvedValue(null);
|
||||
jest
|
||||
.spyOn(fieldRepo, 'save')
|
||||
.mockResolvedValue({ id: faker.number.int() } as FieldEntity);
|
||||
|
||||
const channel = await channelService.create(dto);
|
||||
|
||||
expect(channel.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('creating a channel fails with a duplicate name', async () => {
|
||||
const fieldCount = faker.number.int({ min: 1, max: 10 });
|
||||
const dto = new CreateChannelDto();
|
||||
dto.name = faker.string.sample();
|
||||
dto.description = faker.string.sample();
|
||||
dto.projectId = faker.number.int();
|
||||
dto.feedbackSearchMaxDays = faker.number.int();
|
||||
dto.fields = Array.from({ length: fieldCount }).map(createFieldDto);
|
||||
|
||||
await expect(channelService.create(dto)).rejects.toThrow(
|
||||
ChannelAlreadyExistsException,
|
||||
);
|
||||
});
|
||||
|
||||
it('creating a channel succeeds with empty fields array', async () => {
|
||||
const dto = new CreateChannelDto();
|
||||
dto.name = faker.string.sample();
|
||||
dto.description = faker.string.sample();
|
||||
dto.projectId = channelFixture.project.id;
|
||||
dto.feedbackSearchMaxDays = faker.number.int();
|
||||
dto.fields = [];
|
||||
jest.spyOn(channelRepo, 'findOneBy').mockResolvedValue(null);
|
||||
|
||||
const channel = await channelService.create(dto);
|
||||
|
||||
expect(channel.id).toBeDefined();
|
||||
});
|
||||
|
||||
it('creating a channel fails with invalid project id', async () => {
|
||||
const dto = new CreateChannelDto();
|
||||
dto.name = faker.string.sample();
|
||||
dto.description = faker.string.sample();
|
||||
dto.projectId = faker.number.int();
|
||||
dto.feedbackSearchMaxDays = faker.number.int();
|
||||
dto.fields = [];
|
||||
|
||||
// Mock projectService.findById to throw error
|
||||
jest
|
||||
.spyOn(channelServiceAny.projectService, 'findById')
|
||||
.mockRejectedValue(new Error('Project not found'));
|
||||
|
||||
await expect(channelService.create(dto)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAllByProjectId', () => {
|
||||
it('finding all channels by project id succeeds with valid project id', async () => {
|
||||
const dto = new FindAllChannelsByProjectIdDto();
|
||||
dto.projectId = channelFixture.project.id;
|
||||
dto.options = { limit: 10, page: 1 };
|
||||
dto.searchText = faker.string.sample();
|
||||
|
||||
const mockChannels = {
|
||||
items: [channelFixture],
|
||||
meta: {
|
||||
itemCount: 1,
|
||||
totalItems: 1,
|
||||
itemsPerPage: 10,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.channelMySQLService, 'findAllByProjectId')
|
||||
.mockResolvedValue(mockChannels);
|
||||
|
||||
const result = await channelService.findAllByProjectId(dto);
|
||||
|
||||
expect(result).toEqual(mockChannels);
|
||||
expect(
|
||||
channelServiceAny.channelMySQLService.findAllByProjectId,
|
||||
).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('finding all channels by project id returns empty result', async () => {
|
||||
const dto = new FindAllChannelsByProjectIdDto();
|
||||
dto.projectId = faker.number.int();
|
||||
dto.options = { limit: 10, page: 1 };
|
||||
|
||||
const mockChannels = {
|
||||
items: [],
|
||||
meta: {
|
||||
itemCount: 0,
|
||||
totalItems: 0,
|
||||
itemsPerPage: 10,
|
||||
totalPages: 0,
|
||||
currentPage: 1,
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.channelMySQLService, 'findAllByProjectId')
|
||||
.mockResolvedValue(mockChannels);
|
||||
|
||||
const result = await channelService.findAllByProjectId(dto);
|
||||
|
||||
expect(result.items).toHaveLength(0);
|
||||
expect(result.meta.totalItems).toBe(0);
|
||||
});
|
||||
|
||||
it('finding all channels by project id succeeds without search text', async () => {
|
||||
const dto = new FindAllChannelsByProjectIdDto();
|
||||
dto.projectId = channelFixture.project.id;
|
||||
dto.options = { limit: 10, page: 1 };
|
||||
|
||||
const mockChannels = {
|
||||
items: [channelFixture],
|
||||
meta: {
|
||||
itemCount: 1,
|
||||
totalItems: 1,
|
||||
itemsPerPage: 10,
|
||||
totalPages: 1,
|
||||
currentPage: 1,
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.channelMySQLService, 'findAllByProjectId')
|
||||
.mockResolvedValue(mockChannels);
|
||||
|
||||
const result = await channelService.findAllByProjectId(dto);
|
||||
|
||||
expect(result).toEqual(mockChannels);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('finding by an id succeeds with an existent id', async () => {
|
||||
const dto = new FindByChannelIdDto();
|
||||
dto.channelId = channelFixture.id;
|
||||
|
||||
const result = await channelService.findById(dto);
|
||||
|
||||
expect(result).toMatchObject(expect.objectContaining(channelFixture));
|
||||
});
|
||||
it('finding by an id fails with a nonexistent id', async () => {
|
||||
const dto = new FindByChannelIdDto();
|
||||
dto.channelId = faker.number.int();
|
||||
jest.spyOn(channelRepo, 'findOne').mockResolvedValue(null);
|
||||
|
||||
await expect(channelService.findById(dto)).rejects.toThrow(
|
||||
ChannelNotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkName', () => {
|
||||
it('checking name returns true when channel exists', async () => {
|
||||
const dto = new FindOneByNameAndProjectIdDto();
|
||||
dto.name = channelFixture.name;
|
||||
dto.projectId = channelFixture.project.id;
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.channelMySQLService, 'findOneBy')
|
||||
.mockResolvedValue(channelFixture);
|
||||
|
||||
const result = await channelService.checkName(dto);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(
|
||||
channelServiceAny.channelMySQLService.findOneBy,
|
||||
).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('checking name returns false when channel does not exist', async () => {
|
||||
const dto = new FindOneByNameAndProjectIdDto();
|
||||
dto.name = faker.string.sample();
|
||||
dto.projectId = faker.number.int();
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.channelMySQLService, 'findOneBy')
|
||||
.mockResolvedValue(null);
|
||||
|
||||
const result = await channelService.checkName(dto);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(
|
||||
channelServiceAny.channelMySQLService.findOneBy,
|
||||
).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('updating succeeds with valid inputs', async () => {
|
||||
const channelId = channelFixture.id;
|
||||
const dto = new UpdateChannelDto();
|
||||
dto.name = faker.string.sample();
|
||||
dto.description = faker.string.sample();
|
||||
dto.feedbackSearchMaxDays = faker.number.int();
|
||||
jest.spyOn(channelRepo, 'findOne').mockResolvedValueOnce(channelFixture);
|
||||
jest.spyOn(channelRepo, 'findOne').mockResolvedValueOnce(null);
|
||||
|
||||
const channel = await channelService.updateInfo(channelId, dto);
|
||||
|
||||
expect(channel.name).toEqual(dto.name);
|
||||
expect(channel.description).toEqual(dto.description);
|
||||
});
|
||||
it('updating fails with a duplicate channel name', async () => {
|
||||
const channelId = channelFixture.id;
|
||||
const dto = new UpdateChannelDto();
|
||||
dto.name = channelFixture.name;
|
||||
dto.description = faker.string.sample();
|
||||
dto.feedbackSearchMaxDays = faker.number.int();
|
||||
|
||||
await expect(channelService.updateInfo(channelId, dto)).rejects.toThrow(
|
||||
ChannelInvalidNameException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFields', () => {
|
||||
it('updating fields succeeds with valid inputs', async () => {
|
||||
const channelId = channelFixture.id;
|
||||
const dto = new UpdateChannelFieldsDto();
|
||||
dto.fields = Array.from({ length: 3 }).map(createFieldDto);
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.fieldService, 'replaceMany')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await channelService.updateFields(channelId, dto);
|
||||
|
||||
expect(channelServiceAny.fieldService.replaceMany).toHaveBeenCalledWith({
|
||||
channelId,
|
||||
fields: dto.fields,
|
||||
});
|
||||
});
|
||||
|
||||
it('updating fields succeeds with empty fields array', async () => {
|
||||
const channelId = channelFixture.id;
|
||||
const dto = new UpdateChannelFieldsDto();
|
||||
dto.fields = [];
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.fieldService, 'replaceMany')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await channelService.updateFields(channelId, dto);
|
||||
|
||||
expect(channelServiceAny.fieldService.replaceMany).toHaveBeenCalledWith({
|
||||
channelId,
|
||||
fields: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('updating fields fails when field service throws error', async () => {
|
||||
const channelId = channelFixture.id;
|
||||
const dto = new UpdateChannelFieldsDto();
|
||||
dto.fields = Array.from({ length: 3 }).map(createFieldDto);
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.fieldService, 'replaceMany')
|
||||
.mockRejectedValue(new Error('Field service error'));
|
||||
|
||||
await expect(
|
||||
channelService.updateFields(channelId, dto),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteById', () => {
|
||||
it('deleting by an id succeeds with a valid id', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const channel = new ChannelEntity();
|
||||
channel.id = channelId;
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.channelMySQLService, 'delete')
|
||||
.mockResolvedValue(channel);
|
||||
|
||||
const deletedChannel = await channelService.deleteById(channelId);
|
||||
|
||||
expect(deletedChannel.id).toEqual(channel.id);
|
||||
expect(channelServiceAny.channelMySQLService.delete).toHaveBeenCalledWith(
|
||||
channelId,
|
||||
);
|
||||
});
|
||||
|
||||
it('deleting by an id succeeds with OpenSearch enabled', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const channel = new ChannelEntity();
|
||||
channel.id = channelId;
|
||||
|
||||
// Mock config to enable OpenSearch
|
||||
jest.spyOn(channelServiceAny.configService, 'get').mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(channelServiceAny.osRepository, 'deleteIndex')
|
||||
.mockResolvedValue(undefined);
|
||||
jest
|
||||
.spyOn(channelServiceAny.channelMySQLService, 'delete')
|
||||
.mockResolvedValue(channel);
|
||||
|
||||
const deletedChannel = await channelService.deleteById(channelId);
|
||||
|
||||
expect(deletedChannel.id).toEqual(channel.id);
|
||||
expect(channelServiceAny.osRepository.deleteIndex).toHaveBeenCalledWith(
|
||||
channelId.toString(),
|
||||
);
|
||||
expect(channelServiceAny.channelMySQLService.delete).toHaveBeenCalledWith(
|
||||
channelId,
|
||||
);
|
||||
});
|
||||
|
||||
it('deleting by an id succeeds with OpenSearch disabled', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const channel = new ChannelEntity();
|
||||
channel.id = channelId;
|
||||
|
||||
// Mock config to disable OpenSearch
|
||||
jest.spyOn(channelServiceAny.configService, 'get').mockReturnValue(false);
|
||||
jest
|
||||
.spyOn(channelServiceAny.channelMySQLService, 'delete')
|
||||
.mockResolvedValue(channel);
|
||||
|
||||
const deletedChannel = await channelService.deleteById(channelId);
|
||||
|
||||
expect(deletedChannel.id).toEqual(channel.id);
|
||||
expect(channelServiceAny.osRepository.deleteIndex).not.toHaveBeenCalled();
|
||||
expect(channelServiceAny.channelMySQLService.delete).toHaveBeenCalledWith(
|
||||
channelId,
|
||||
);
|
||||
});
|
||||
|
||||
it('deleting by an id fails when MySQL service throws error', async () => {
|
||||
const channelId = faker.number.int();
|
||||
|
||||
jest
|
||||
.spyOn(channelServiceAny.channelMySQLService, 'delete')
|
||||
.mockRejectedValue(new Error('MySQL service error'));
|
||||
|
||||
await expect(channelService.deleteById(channelId)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
GetObjectCommand,
|
||||
ListObjectsCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import type {
|
||||
CreateImageDownloadUrlDto,
|
||||
CreateImageUploadUrlDto,
|
||||
ImageUploadUrlTestDto,
|
||||
} from '../../feedback/dtos';
|
||||
import { FieldService } from '../field/field.service';
|
||||
import { ChannelMySQLService } from './channel.mysql.service';
|
||||
import type {
|
||||
FindAllChannelsByProjectIdDto,
|
||||
FindByChannelIdDto,
|
||||
FindOneByNameAndProjectIdDto,
|
||||
} from './dtos';
|
||||
import {
|
||||
CreateChannelDto,
|
||||
UpdateChannelDto,
|
||||
UpdateChannelFieldsDto,
|
||||
} from './dtos';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelService {
|
||||
constructor(
|
||||
private readonly channelMySQLService: ChannelMySQLService,
|
||||
private readonly osRepository: OpensearchRepository,
|
||||
private readonly projectService: ProjectService,
|
||||
private readonly fieldService: FieldService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async checkName(dto: FindOneByNameAndProjectIdDto) {
|
||||
const res = await this.channelMySQLService.findOneBy(dto);
|
||||
return !!res;
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async create(dto: CreateChannelDto) {
|
||||
await this.projectService.findById({ projectId: dto.projectId });
|
||||
|
||||
const { id } = await this.channelMySQLService.create(dto);
|
||||
if (this.configService.get('opensearch.use')) {
|
||||
await this.osRepository.createIndex({ index: id.toString() });
|
||||
}
|
||||
|
||||
const fields = dto.fields;
|
||||
await this.fieldService.createMany({ channelId: id, fields });
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
async findAllByProjectId(dto: FindAllChannelsByProjectIdDto) {
|
||||
return await this.channelMySQLService.findAllByProjectId(dto);
|
||||
}
|
||||
|
||||
async findById(dto: FindByChannelIdDto) {
|
||||
return await this.channelMySQLService.findById(dto);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async updateInfo(channelId: number, dto: UpdateChannelDto) {
|
||||
return await this.channelMySQLService.update(channelId, dto);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async updateFields(channelId: number, dto: UpdateChannelFieldsDto) {
|
||||
await this.fieldService.replaceMany({
|
||||
channelId: channelId,
|
||||
fields: dto.fields,
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async deleteById(channelId: number) {
|
||||
if (this.configService.get('opensearch.use')) {
|
||||
await this.osRepository.deleteIndex(channelId.toString());
|
||||
}
|
||||
|
||||
return await this.channelMySQLService.delete(channelId);
|
||||
}
|
||||
|
||||
async createImageUploadUrl(dto: CreateImageUploadUrlDto) {
|
||||
const {
|
||||
projectId,
|
||||
channelId,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
endpoint,
|
||||
region,
|
||||
bucket,
|
||||
extension,
|
||||
} = dto;
|
||||
|
||||
const s3 = new S3Client({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
endpoint,
|
||||
region,
|
||||
});
|
||||
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: `${projectId}_${channelId}_${Date.now()}.${extension}`,
|
||||
ContentType: 'image/*',
|
||||
ACL: 'public-read',
|
||||
});
|
||||
|
||||
return await getSignedUrl(s3, command, { expiresIn: 60 * 60 });
|
||||
}
|
||||
|
||||
async createImageDownloadUrl(dto: CreateImageDownloadUrlDto) {
|
||||
const { accessKeyId, secretAccessKey, endpoint, region, bucket, imageKey } =
|
||||
dto;
|
||||
|
||||
const s3 = new S3Client({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
endpoint,
|
||||
region,
|
||||
});
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: imageKey,
|
||||
});
|
||||
|
||||
return await getSignedUrl(s3, command, { expiresIn: 60 });
|
||||
}
|
||||
|
||||
async isValidImageConfig(dto: ImageUploadUrlTestDto) {
|
||||
const { accessKeyId, secretAccessKey, endpoint, region, bucket } = dto;
|
||||
|
||||
const s3 = new S3Client({
|
||||
credentials: { accessKeyId, secretAccessKey },
|
||||
endpoint,
|
||||
region,
|
||||
});
|
||||
|
||||
const command = new ListObjectsCommand({ Bucket: bucket });
|
||||
|
||||
try {
|
||||
await s3.send(command);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Expose, plainToInstance, Type } from 'class-transformer';
|
||||
|
||||
import { CreateFieldDto } from '../../field/dtos/create-field.dto';
|
||||
import { ChannelEntity } from '../channel.entity';
|
||||
import type { ImageConfigDto } from './image-config.dto';
|
||||
|
||||
export class CreateChannelDto {
|
||||
@Expose()
|
||||
projectId: number;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
imageConfig: ImageConfigDto | null;
|
||||
|
||||
@Expose()
|
||||
feedbackSearchMaxDays: number;
|
||||
|
||||
@Expose()
|
||||
@Type(() => CreateFieldDto)
|
||||
fields: CreateFieldDto[];
|
||||
|
||||
public static from(params: any): CreateChannelDto {
|
||||
return plainToInstance(CreateChannelDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
|
||||
static toChannelEntity(params: CreateChannelDto) {
|
||||
return ChannelEntity.from(
|
||||
params.name,
|
||||
params.description,
|
||||
params.projectId,
|
||||
params.imageConfig,
|
||||
params.feedbackSearchMaxDays,
|
||||
);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { IPaginationOptions } from 'nestjs-typeorm-paginate';
|
||||
|
||||
export class FindAllChannelsByProjectIdDto {
|
||||
options: IPaginationOptions;
|
||||
projectId: number;
|
||||
searchText?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class FindByChannelIdDto {
|
||||
channelId: number;
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class FindOneByNameAndProjectIdDto {
|
||||
name: string;
|
||||
projectId: number;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Expose, plainToInstance } from 'class-transformer';
|
||||
|
||||
export class ImageConfigDto {
|
||||
@Expose()
|
||||
accessKeyId: string;
|
||||
|
||||
@Expose()
|
||||
secretAccessKey: string;
|
||||
|
||||
@Expose()
|
||||
endpoint: string;
|
||||
|
||||
@Expose()
|
||||
region: string;
|
||||
|
||||
@Expose()
|
||||
bucket: string;
|
||||
|
||||
@Expose()
|
||||
domainWhiteList: string[];
|
||||
|
||||
public static from(params: any): ImageConfigDto {
|
||||
return plainToInstance(ImageConfigDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { FindAllChannelsByProjectIdDto } from './find-all-channels-by-project-id.dto';
|
||||
export { CreateChannelDto } from './create-channel.dto';
|
||||
export { UpdateChannelDto } from './update-channel.dto';
|
||||
export { FindByChannelIdDto } from './find-by-channel-id.dto';
|
||||
export { UpdateChannelFieldsDto } from './update-channel-fields.dto';
|
||||
export { FindOneByNameAndProjectIdDto } from './find-one-by-name-and-project-id.dto';
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import {
|
||||
FieldFormatEnum,
|
||||
FieldPropertyEnum,
|
||||
FieldStatusEnum,
|
||||
} from '@/common/enums';
|
||||
import { IsNullable } from '@/domains/admin/user/decorators';
|
||||
import { ImageConfigRequestDto } from './image-config-request.dto';
|
||||
|
||||
class CreateChannelRequestFieldSelectOptionDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
id?: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class CreateChannelRequestFieldDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
key: string;
|
||||
|
||||
@ApiProperty({ nullable: true, type: String })
|
||||
@IsNullable()
|
||||
@IsString()
|
||||
description: string | null;
|
||||
|
||||
@ApiProperty({ enum: FieldFormatEnum, enumName: 'FieldFormatEnum' })
|
||||
@IsEnum(FieldFormatEnum)
|
||||
format: FieldFormatEnum;
|
||||
|
||||
@ApiProperty({ enum: FieldPropertyEnum, enumName: 'FieldPropertyEnum' })
|
||||
@IsEnum(FieldPropertyEnum)
|
||||
property: FieldPropertyEnum;
|
||||
|
||||
@ApiProperty({ enum: FieldStatusEnum, enumName: 'FieldStatusEnum' })
|
||||
@IsEnum(FieldStatusEnum)
|
||||
status: FieldStatusEnum;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Number })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
order?: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Number, required: false })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
aiFieldTemplateId?: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true, type: [String], required: false })
|
||||
@IsOptional()
|
||||
aiFieldTargetKeys?: string[] | null;
|
||||
|
||||
@ApiProperty({ nullable: true, type: Boolean, required: false })
|
||||
@IsOptional()
|
||||
aiFieldAutoProcessing?: boolean | null;
|
||||
|
||||
@ApiProperty({
|
||||
type: [CreateChannelRequestFieldSelectOptionDto],
|
||||
required: false,
|
||||
})
|
||||
@Type(() => CreateChannelRequestFieldSelectOptionDto)
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
options?: CreateChannelRequestFieldSelectOptionDto[];
|
||||
}
|
||||
|
||||
export class CreateChannelRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(20)
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ nullable: true, type: String })
|
||||
@IsNullable()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
description: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: ImageConfigRequestDto })
|
||||
@IsOptional()
|
||||
@IsNullable()
|
||||
@IsObject()
|
||||
imageConfig: ImageConfigRequestDto | null;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
feedbackSearchMaxDays: number;
|
||||
|
||||
@ApiProperty({ type: [CreateChannelRequestFieldDto] })
|
||||
@Type(() => CreateChannelRequestFieldDto)
|
||||
@ValidateNested({ each: true })
|
||||
fields: CreateChannelRequestFieldDto[];
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { PaginationRequestDto } from '@/common/dtos';
|
||||
|
||||
export class FindChannelsByProjectIdRequestDto extends PaginationRequestDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
searchText: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsString } from 'class-validator';
|
||||
|
||||
export class ImageConfigRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
accessKeyId: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
secretAccessKey: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
endpoint: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
region: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
bucket: string;
|
||||
|
||||
@ApiProperty({ nullable: true, type: [String] })
|
||||
@IsString({ each: true })
|
||||
domainWhiteList: string[];
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsBoolean()
|
||||
enablePresignedUrlDownload: boolean;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class ImageUploadUrlTestRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
accessKeyId: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
secretAccessKey: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
endpoint: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
region: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
bucket: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { CreateChannelRequestDto } from './create-channel-request.dto';
|
||||
export { FindChannelsByProjectIdRequestDto } from './find-channels-by-project-id-request.dto';
|
||||
export { CreateChannelRequestFieldDto } from './create-channel-request.dto';
|
||||
export { ImageConfigRequestDto } from './image-config-request.dto';
|
||||
export { UpdateChannelRequestDto } from './update-channel-request.dto';
|
||||
export { UpdateChannelFieldsRequestDto } from './update-channel-fields-request.dto';
|
||||
export { UpdateChannelRequestFieldDto } from './update-channel-fields-request.dto';
|
||||
export { ImageUploadUrlTestRequestDto } from './image-upload-url-test-request.dto';
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, ValidateNested } from 'class-validator';
|
||||
|
||||
import { CreateChannelRequestFieldDto } from './create-channel-request.dto';
|
||||
|
||||
export class UpdateChannelRequestFieldDto extends CreateChannelRequestFieldDto {
|
||||
@ApiProperty({ required: false })
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
id?: number;
|
||||
}
|
||||
|
||||
export class UpdateChannelFieldsRequestDto {
|
||||
@ApiProperty({ type: [UpdateChannelRequestFieldDto] })
|
||||
@Type(() => UpdateChannelRequestFieldDto)
|
||||
@ValidateNested({ each: true })
|
||||
fields: UpdateChannelRequestFieldDto[];
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import {
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsNullable } from '@/domains/admin/user/decorators';
|
||||
import { ImageConfigRequestDto } from './image-config-request.dto';
|
||||
|
||||
export class UpdateChannelRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(20)
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ nullable: true, type: String })
|
||||
@IsNullable()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
description: string | null;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true, type: ImageConfigRequestDto })
|
||||
@IsOptional()
|
||||
@IsNullable()
|
||||
@IsObject()
|
||||
imageConfig: ImageConfigRequestDto | null;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
feedbackSearchMaxDays: number;
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, plainToInstance } from 'class-transformer';
|
||||
|
||||
export class CreateChannelResponseDto {
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
public static transform(params: any): CreateChannelResponseDto {
|
||||
return plainToInstance(CreateChannelResponseDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, plainToInstance, Type } from 'class-transformer';
|
||||
|
||||
import { FindFieldsResponseDto } from '@/domains/admin/channel/field/dtos/responses';
|
||||
import { ImageConfigResponseDto } from './image-config-response.dto';
|
||||
|
||||
export class FindChannelByIdResponseDto {
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
description: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ required: false })
|
||||
imageConfig: ImageConfigResponseDto;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
feedbackSearchMaxDays: number;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ type: [FindFieldsResponseDto] })
|
||||
@Type(() => FindFieldsResponseDto)
|
||||
fields: FindFieldsResponseDto[];
|
||||
|
||||
public static transform(params: any): FindChannelByIdResponseDto {
|
||||
params.fields = params.fields.map((field: any) => {
|
||||
if (field.aiFieldTemplate) {
|
||||
field.aiFieldTemplateId = field.aiFieldTemplate.id;
|
||||
}
|
||||
return field;
|
||||
});
|
||||
return plainToInstance(FindChannelByIdResponseDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, plainToInstance, Type } from 'class-transformer';
|
||||
|
||||
import { PaginationResponseDto } from '@/common/dtos';
|
||||
import { ImageConfigResponseDto } from './image-config-response.dto';
|
||||
|
||||
class FindChannelsByProjectDto {
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
description: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
feedbackSearchMaxDays: number;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
imageConfig: ImageConfigResponseDto;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class FindChannelsByProjectIdResponseDto extends PaginationResponseDto<FindChannelsByProjectDto> {
|
||||
@Expose()
|
||||
@ApiProperty({ type: [FindChannelsByProjectDto] })
|
||||
@Type(() => FindChannelsByProjectDto)
|
||||
items: FindChannelsByProjectDto[];
|
||||
|
||||
public static transform(params: any): FindChannelsByProjectIdResponseDto {
|
||||
return plainToInstance(FindChannelsByProjectIdResponseDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose } from 'class-transformer';
|
||||
|
||||
export class ImageConfigResponseDto {
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
accessKeyId: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
secretAccessKey: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
endpoint: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
region: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
bucket: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
domainWhiteList: string[];
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ required: false, type: 'boolean' })
|
||||
enablePresignedUrlDownload: boolean | undefined;
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, plainToInstance } from 'class-transformer';
|
||||
|
||||
export class ImageUploadUrlTestResponseDto {
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
success: boolean;
|
||||
public static transform(
|
||||
params: Partial<ImageUploadUrlTestResponseDto>,
|
||||
): ImageUploadUrlTestResponseDto {
|
||||
return plainToInstance(ImageUploadUrlTestResponseDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { CreateChannelResponseDto } from './create-channel-response.dto';
|
||||
export { FindChannelByIdResponseDto } from './find-channel-by-id-response.dto';
|
||||
export { FindChannelsByProjectIdResponseDto } from './find-channels-by-id-response.dto';
|
||||
export { ImageConfigResponseDto } from './image-config-response.dto';
|
||||
export { ImageUploadUrlTestResponseDto } from './image-upload-url-test-response.dto';
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
|
||||
import { ReplaceFieldDto } from '../../field/dtos';
|
||||
|
||||
export class UpdateChannelFieldsDto {
|
||||
@Expose()
|
||||
@Type(() => ReplaceFieldDto)
|
||||
fields: ReplaceFieldDto[];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Expose } from 'class-transformer';
|
||||
|
||||
import type { ImageConfigDto } from './image-config.dto';
|
||||
|
||||
export class UpdateChannelDto {
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
imageConfig: ImageConfigDto | null;
|
||||
|
||||
@Expose()
|
||||
feedbackSearchMaxDays: number;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class ChannelAlreadyExistsException extends BadRequestException {
|
||||
constructor() {
|
||||
super({
|
||||
code: ErrorCode.Channel.ChannelAlreadyExists,
|
||||
message: 'channel already exists',
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class ChannelInvalidNameException extends BadRequestException {
|
||||
constructor(description: string) {
|
||||
super({
|
||||
code: ErrorCode.Channel.ChannelInvalidName,
|
||||
message: `Channel name is invalid: ${description}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class ChannelNotFoundException extends BadRequestException {
|
||||
constructor() {
|
||||
super({
|
||||
code: ErrorCode.Channel.ChannelNotFound,
|
||||
message: 'channel is not found',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { ChannelAlreadyExistsException } from './channel-already-exists.exception';
|
||||
export { ChannelNotFoundException } from './channel-not-found.exception';
|
||||
export { ChannelInvalidNameException } from './channel-invalid-name.exception';
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
|
||||
import {
|
||||
FieldFormatEnum,
|
||||
FieldPropertyEnum,
|
||||
FieldStatusEnum,
|
||||
} from '../../../../../common/enums';
|
||||
|
||||
export class CreateFieldDto {
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
key: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
format: FieldFormatEnum;
|
||||
|
||||
@Expose()
|
||||
property: FieldPropertyEnum;
|
||||
|
||||
@Expose()
|
||||
status: FieldStatusEnum;
|
||||
|
||||
@Expose()
|
||||
@Type(() => Option)
|
||||
options?: Option[];
|
||||
|
||||
@Expose()
|
||||
order?: number | null;
|
||||
|
||||
@Expose()
|
||||
aiFieldTemplateId?: number | null;
|
||||
|
||||
@Expose()
|
||||
aiFieldTargetKeys?: string[] | null;
|
||||
|
||||
@Expose()
|
||||
aiFieldAutoProcessing?: boolean | null;
|
||||
}
|
||||
|
||||
class Option {
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
key: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { CreateFieldDto } from './create-field.dto';
|
||||
|
||||
export class CreateManyFieldsDto {
|
||||
channelId: number;
|
||||
fields: CreateFieldDto[];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { CreateManyFieldsDto } from './create-many-fields.dto';
|
||||
export { ReplaceManyFieldsDto } from './replace-many-fields.dto';
|
||||
export { CreateFieldDto } from './create-field.dto';
|
||||
export { ReplaceFieldDto } from './replace-field.dto';
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
|
||||
import {
|
||||
FieldFormatEnum,
|
||||
FieldPropertyEnum,
|
||||
FieldStatusEnum,
|
||||
} from '../../../../../common/enums';
|
||||
|
||||
export class ReplaceFieldDto {
|
||||
@Expose()
|
||||
id?: number;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
key: string;
|
||||
|
||||
@Expose()
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
format: FieldFormatEnum;
|
||||
|
||||
@Expose()
|
||||
property: FieldPropertyEnum;
|
||||
|
||||
@Expose()
|
||||
status: FieldStatusEnum;
|
||||
|
||||
@Expose()
|
||||
order?: number | null;
|
||||
|
||||
@Expose()
|
||||
aiFieldTemplateId?: number | null;
|
||||
|
||||
@Expose()
|
||||
aiFieldTargetKeys?: string[] | null;
|
||||
|
||||
@Expose()
|
||||
aiFieldAutoProcessing?: boolean | null;
|
||||
|
||||
@Expose()
|
||||
@Type(() => Option)
|
||||
options?: Option[];
|
||||
}
|
||||
|
||||
class Option {
|
||||
@Expose()
|
||||
id?: number;
|
||||
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
key: string;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ReplaceFieldDto } from './replace-field.dto';
|
||||
|
||||
export class ReplaceManyFieldsDto {
|
||||
channelId: number;
|
||||
fields: ReplaceFieldDto[];
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, plainToInstance, Type } from 'class-transformer';
|
||||
|
||||
import {
|
||||
FieldFormatEnum,
|
||||
FieldPropertyEnum,
|
||||
FieldStatusEnum,
|
||||
} from '@/common/enums';
|
||||
|
||||
export class FindFieldsResponseSelectOptionDto {
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
key: string;
|
||||
}
|
||||
|
||||
export class FindFieldsResponseDto {
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ enum: FieldFormatEnum })
|
||||
format: FieldFormatEnum;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ enum: FieldPropertyEnum })
|
||||
property: FieldFormatEnum;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ enum: FieldStatusEnum })
|
||||
status: FieldFormatEnum;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
order: number;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
name: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
key: string;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ nullable: true, type: String })
|
||||
description: string | null;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ type: [FindFieldsResponseSelectOptionDto] })
|
||||
@Type(() => FindFieldsResponseSelectOptionDto)
|
||||
options: FindFieldsResponseSelectOptionDto[];
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ type: Number, nullable: true })
|
||||
aiFieldTemplateId: number | null;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ type: Array<string>, nullable: true })
|
||||
aiFieldTargetKeys: string[] | null;
|
||||
|
||||
@Expose()
|
||||
@ApiProperty({ type: Boolean })
|
||||
aiFieldAutoProcessing: boolean | null;
|
||||
|
||||
public static transform(params: any): FindFieldsResponseDto {
|
||||
return plainToInstance(FindFieldsResponseDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class GetFieldsResponseDto {
|
||||
@ApiProperty({ example: 1 })
|
||||
id: number;
|
||||
|
||||
@ApiProperty({ example: 'fieldName' })
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: 'fieldKey' })
|
||||
key: string;
|
||||
|
||||
@ApiProperty({ enum: FieldFormatEnum })
|
||||
format: FieldFormatEnum;
|
||||
|
||||
@ApiProperty({ enum: FieldStatusEnum })
|
||||
status: FieldStatusEnum;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { FindFieldsResponseDto } from './find-fields-response.dto';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class FieldKeyDuplicatedException extends BadRequestException {
|
||||
constructor() {
|
||||
super({
|
||||
code: ErrorCode.Field.FieldKeyDuplicated,
|
||||
message: 'field key is duplicated',
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class FieldNameDuplicatedException extends BadRequestException {
|
||||
constructor() {
|
||||
super({
|
||||
code: ErrorCode.Field.FieldNameDuplicated,
|
||||
message: 'field name is duplicated',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { FieldNameDuplicatedException } from './field-name-duplicated.exception';
|
||||
export { FieldKeyDuplicatedException } from './field-key-duplicated.exception';
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export const RESERVED_FIELD_NAMES = ['ID', 'Created', 'Updated', 'Issue'];
|
||||
|
||||
export const RESERVED_FIELD_KEYS = [
|
||||
'id',
|
||||
'ids',
|
||||
'issues',
|
||||
'issueIds',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
];
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
Relation,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
|
||||
import { CommonEntity } from '@/common/entities';
|
||||
import {
|
||||
FieldFormatEnum,
|
||||
FieldPropertyEnum,
|
||||
FieldStatusEnum,
|
||||
} from '../../../../common/enums';
|
||||
import { AIFieldTemplatesEntity } from '../../project/ai/ai-field-templates.entity';
|
||||
import { ChannelEntity } from '../channel/channel.entity';
|
||||
import { OptionEntity } from '../option/option.entity';
|
||||
|
||||
@Entity('fields')
|
||||
@Index(['createdAt'])
|
||||
@Unique('field-key-unique', ['key', 'channel'])
|
||||
@Unique('field-name-unique', ['name', 'channel'])
|
||||
export class FieldEntity extends CommonEntity {
|
||||
@Column('varchar')
|
||||
name: string;
|
||||
|
||||
@Column('varchar')
|
||||
key: string;
|
||||
|
||||
@Column('varchar', { nullable: true })
|
||||
description: string | null;
|
||||
|
||||
@Column('enum', { enum: FieldFormatEnum })
|
||||
format: FieldFormatEnum;
|
||||
|
||||
@Column('enum', { enum: FieldPropertyEnum })
|
||||
property: FieldPropertyEnum;
|
||||
|
||||
@Column('enum', { enum: FieldStatusEnum })
|
||||
status: FieldStatusEnum;
|
||||
|
||||
@Column('int', { default: 0 })
|
||||
order: number | null;
|
||||
|
||||
@ManyToOne(
|
||||
() => AIFieldTemplatesEntity,
|
||||
(aiFieldTemplate) => aiFieldTemplate.fields,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
orphanedRowAction: 'delete',
|
||||
nullable: true,
|
||||
},
|
||||
)
|
||||
aiFieldTemplate: Relation<AIFieldTemplatesEntity> | null;
|
||||
|
||||
@Column('json', { nullable: true })
|
||||
aiFieldTargetKeys: string[] | null;
|
||||
|
||||
@Column('boolean', { nullable: true })
|
||||
aiFieldAutoProcessing: boolean | null;
|
||||
|
||||
@ManyToOne(() => ChannelEntity, (channel) => channel.fields, {
|
||||
onDelete: 'CASCADE',
|
||||
orphanedRowAction: 'delete',
|
||||
})
|
||||
channel: Relation<ChannelEntity>;
|
||||
|
||||
@OneToMany(() => OptionEntity, (option) => option.field, {
|
||||
nullable: true,
|
||||
cascade: true,
|
||||
})
|
||||
options: Relation<OptionEntity>[] | undefined;
|
||||
|
||||
static from({
|
||||
channelId,
|
||||
name,
|
||||
key,
|
||||
description,
|
||||
format,
|
||||
property,
|
||||
status,
|
||||
order,
|
||||
aiFieldTemplateId,
|
||||
aiFieldTargetKeys,
|
||||
aiFieldAutoProcessing,
|
||||
}: {
|
||||
channelId: number;
|
||||
name: string;
|
||||
key: string;
|
||||
description: string | null;
|
||||
format: FieldFormatEnum;
|
||||
property: FieldPropertyEnum;
|
||||
status: FieldStatusEnum;
|
||||
order?: number | null;
|
||||
aiFieldTemplateId?: number | null;
|
||||
aiFieldTargetKeys?: string[] | null;
|
||||
aiFieldAutoProcessing?: boolean | null;
|
||||
}) {
|
||||
const field = new FieldEntity();
|
||||
field.channel = new ChannelEntity();
|
||||
field.channel.id = channelId;
|
||||
field.name = name;
|
||||
field.key = key;
|
||||
field.description = description;
|
||||
field.format = format;
|
||||
field.property = property;
|
||||
field.status = status;
|
||||
field.order = order ?? 0;
|
||||
if (aiFieldTemplateId) {
|
||||
field.aiFieldTemplate = new AIFieldTemplatesEntity();
|
||||
field.aiFieldTemplate.id = aiFieldTemplateId;
|
||||
}
|
||||
field.aiFieldTargetKeys = aiFieldTargetKeys ?? null;
|
||||
field.aiFieldAutoProcessing = aiFieldAutoProcessing ?? null;
|
||||
|
||||
return field;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { OptionEntity } from '../option/option.entity';
|
||||
import { OptionModule } from '../option/option.module';
|
||||
import { FieldEntity } from './field.entity';
|
||||
import { FieldMySQLService } from './field.mysql.service';
|
||||
import { FieldService } from './field.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([FieldEntity, OptionEntity]),
|
||||
OptionModule,
|
||||
],
|
||||
providers: [OpensearchRepository, FieldService, FieldMySQLService],
|
||||
exports: [FieldService, FieldMySQLService],
|
||||
})
|
||||
export class FieldModule {}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
|
||||
import {
|
||||
FieldFormatEnum,
|
||||
FieldPropertyEnum,
|
||||
FieldStatusEnum,
|
||||
isSelectFieldFormat,
|
||||
} from '@/common/enums';
|
||||
import { validateUnique } from '@/utils/validate-unique';
|
||||
import { AIFieldTemplatesEntity } from '../../project/ai/ai-field-templates.entity';
|
||||
import { OptionService } from '../option/option.service';
|
||||
import type { CreateFieldDto, ReplaceFieldDto } from './dtos';
|
||||
import { CreateManyFieldsDto, ReplaceManyFieldsDto } from './dtos';
|
||||
import {
|
||||
FieldKeyDuplicatedException,
|
||||
FieldNameDuplicatedException,
|
||||
} from './exceptions';
|
||||
import { RESERVED_FIELD_KEYS, RESERVED_FIELD_NAMES } from './field.constants';
|
||||
import { FieldEntity } from './field.entity';
|
||||
|
||||
@Injectable()
|
||||
export class FieldMySQLService {
|
||||
constructor(
|
||||
@InjectRepository(FieldEntity)
|
||||
private readonly repository: Repository<FieldEntity>,
|
||||
private readonly optionService: OptionService,
|
||||
) {}
|
||||
|
||||
private checkValidation(fields: (CreateFieldDto | ReplaceFieldDto)[]) {
|
||||
if (!validateUnique(fields, 'name')) {
|
||||
throw new FieldNameDuplicatedException();
|
||||
}
|
||||
if (!validateUnique(fields, 'key')) {
|
||||
throw new FieldKeyDuplicatedException();
|
||||
}
|
||||
fields.forEach(({ key }) => {
|
||||
if (/^[a-z0-9_]+$/i.test(key) === false) {
|
||||
throw new BadRequestException(
|
||||
'field key only should contain alphanumeric and underscore',
|
||||
);
|
||||
}
|
||||
});
|
||||
fields.forEach(({ format, options }) => {
|
||||
if (!this.isValidField(format, options ?? [])) {
|
||||
throw new BadRequestException('only select format field has options');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private checkReservedFieldName(fields: (CreateFieldDto | ReplaceFieldDto)[]) {
|
||||
fields.forEach(({ name }) => {
|
||||
if (RESERVED_FIELD_NAMES.includes(name)) {
|
||||
throw new BadRequestException('name is rejected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private checkReservedFieldKey(fields: (CreateFieldDto | ReplaceFieldDto)[]) {
|
||||
fields.forEach(({ key }) => {
|
||||
if (RESERVED_FIELD_KEYS.includes(key)) {
|
||||
throw new BadRequestException('key is rejected');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private isValidField(
|
||||
type: FieldFormatEnum,
|
||||
options: { id?: number; name: string }[],
|
||||
) {
|
||||
if (isSelectFieldFormat(type)) {
|
||||
return options.length === 0 || Array.isArray(options);
|
||||
} else {
|
||||
return options.length === 0 || !Array.isArray(options);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async createMany({ channelId, fields }: CreateManyFieldsDto) {
|
||||
this.checkValidation(fields);
|
||||
this.checkReservedFieldName(fields);
|
||||
this.checkReservedFieldKey(fields);
|
||||
|
||||
const fieldsToCreate: CreateFieldDto[] = [
|
||||
{
|
||||
name: 'ID',
|
||||
key: 'id',
|
||||
format: FieldFormatEnum.number,
|
||||
property: FieldPropertyEnum.READ_ONLY,
|
||||
status: FieldStatusEnum.ACTIVE,
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
name: 'Created',
|
||||
key: 'createdAt',
|
||||
format: FieldFormatEnum.date,
|
||||
property: FieldPropertyEnum.READ_ONLY,
|
||||
status: FieldStatusEnum.ACTIVE,
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
name: 'Updated',
|
||||
key: 'updatedAt',
|
||||
format: FieldFormatEnum.date,
|
||||
property: FieldPropertyEnum.READ_ONLY,
|
||||
status: FieldStatusEnum.ACTIVE,
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
name: 'Issue',
|
||||
key: 'issues',
|
||||
format: FieldFormatEnum.multiSelect,
|
||||
property: FieldPropertyEnum.EDITABLE,
|
||||
status: FieldStatusEnum.ACTIVE,
|
||||
description: '',
|
||||
},
|
||||
...fields,
|
||||
];
|
||||
|
||||
const fieldEntities: FieldEntity[] = [];
|
||||
for (const field of fieldsToCreate) {
|
||||
const { format, options = [] } = field;
|
||||
const newField = FieldEntity.from({ channelId, ...field });
|
||||
const { id } = await this.repository.save(newField);
|
||||
fieldEntities.push(newField);
|
||||
|
||||
if (isSelectFieldFormat(format) && options.length > 0) {
|
||||
await this.optionService.createMany({
|
||||
fieldId: id,
|
||||
options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return fieldEntities;
|
||||
}
|
||||
|
||||
async findByChannelId({ channelId }: { channelId: number }) {
|
||||
return await this.repository.find({
|
||||
where: { channel: { id: channelId } },
|
||||
relations: { options: true },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async replaceMany({ channelId, fields }: ReplaceManyFieldsDto) {
|
||||
this.checkValidation(fields);
|
||||
|
||||
const creatingFieldDtos = fields.filter((v) => !v.id);
|
||||
this.checkReservedFieldName(creatingFieldDtos);
|
||||
this.checkReservedFieldKey(creatingFieldDtos);
|
||||
|
||||
const updatingFieldDtos = fields.filter(
|
||||
(v) =>
|
||||
v.id &&
|
||||
(!['id', 'createdAt', 'updatedAt', 'issues'].includes(v.key) ||
|
||||
v.order),
|
||||
);
|
||||
|
||||
const fieldEntities = await this.repository.findBy({
|
||||
channel: { id: channelId },
|
||||
});
|
||||
|
||||
for (const { id = 0, format, key, options, ...rest } of updatingFieldDtos) {
|
||||
const fieldEntity = fieldEntities.find((v) => v.id === id);
|
||||
if (!fieldEntity) {
|
||||
throw new BadRequestException('field must be included');
|
||||
}
|
||||
if (format !== fieldEntity.format) {
|
||||
throw new BadRequestException('field format cannot be changed');
|
||||
}
|
||||
if (key !== fieldEntity.key) {
|
||||
throw new BadRequestException('field key cannot be changed');
|
||||
}
|
||||
const field = Object.assign(fieldEntity, rest);
|
||||
if (rest.aiFieldTemplateId) {
|
||||
field.aiFieldTemplate = new AIFieldTemplatesEntity();
|
||||
field.aiFieldTemplate.id = rest.aiFieldTemplateId;
|
||||
}
|
||||
await this.repository.save(field);
|
||||
if (isSelectFieldFormat(format)) {
|
||||
await this.optionService.replaceMany({ fieldId: id, options });
|
||||
}
|
||||
}
|
||||
|
||||
const createdFields: FieldEntity[] = [];
|
||||
for (const field of creatingFieldDtos) {
|
||||
const { format, options = [] } = field;
|
||||
const newField = FieldEntity.from({ channelId, ...field });
|
||||
const createdField = await this.repository.save(newField);
|
||||
createdFields.push(createdField);
|
||||
|
||||
if (isSelectFieldFormat(format) && options.length > 0) {
|
||||
await this.optionService.createMany({
|
||||
fieldId: createdField.id,
|
||||
options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return createdFields;
|
||||
}
|
||||
|
||||
async findByIds(ids: number[]) {
|
||||
const fields = await this.repository.find({
|
||||
where: { id: In(ids) },
|
||||
withDeleted: true,
|
||||
});
|
||||
const fieldMap = new Map(fields.map((field) => [field.id, field]));
|
||||
return ids.map((id) => fieldMap.get(id) ?? new FieldEntity());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import type { Indices_PutMapping_Response } from '@opensearch-project/opensearch/api';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { FieldFormatEnum, isSelectFieldFormat } from '@/common/enums';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { createFieldDto, updateFieldDto } from '@/test-utils/fixtures';
|
||||
import { TestConfig } from '@/test-utils/util-functions';
|
||||
import { FieldServiceProviders } from '../../../../test-utils/providers/field.service.providers';
|
||||
import { OptionEntity } from '../option/option.entity';
|
||||
import type { CreateFieldDto, ReplaceFieldDto } from './dtos';
|
||||
import { CreateManyFieldsDto, ReplaceManyFieldsDto } from './dtos';
|
||||
import {
|
||||
FieldKeyDuplicatedException,
|
||||
FieldNameDuplicatedException,
|
||||
} from './exceptions';
|
||||
import { FieldEntity } from './field.entity';
|
||||
import { FieldMySQLService } from './field.mysql.service';
|
||||
import { FieldService } from './field.service';
|
||||
|
||||
const countSelect = (prev: number, curr: CreateFieldDto): number => {
|
||||
return (
|
||||
isSelectFieldFormat(curr.format) &&
|
||||
curr.options &&
|
||||
curr.options.length > 0
|
||||
) ?
|
||||
prev + 1
|
||||
: prev;
|
||||
};
|
||||
|
||||
describe('FieldService suite', () => {
|
||||
let fieldService: FieldService;
|
||||
let fieldRepo: Repository<FieldEntity>;
|
||||
let optionRepo: Repository<OptionEntity>;
|
||||
let fieldMySQLService: FieldMySQLService;
|
||||
let osRepository: OpensearchRepository;
|
||||
let configService: ConfigService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [TestConfig],
|
||||
providers: FieldServiceProviders,
|
||||
}).compile();
|
||||
|
||||
fieldService = module.get<FieldService>(FieldService);
|
||||
fieldRepo = module.get(getRepositoryToken(FieldEntity));
|
||||
optionRepo = module.get(getRepositoryToken(OptionEntity));
|
||||
fieldMySQLService = module.get<FieldMySQLService>(FieldMySQLService);
|
||||
osRepository = module.get<OpensearchRepository>(OpensearchRepository);
|
||||
configService = module.get<ConfigService>(ConfigService);
|
||||
});
|
||||
|
||||
describe('fieldsToMapping', () => {
|
||||
it('should create correct mapping for text field', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'testText',
|
||||
format: FieldFormatEnum.text,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(mapping.testText).toEqual({
|
||||
type: 'text',
|
||||
analyzer: 'ngram_analyzer',
|
||||
search_analyzer: 'ngram_analyzer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create correct mapping for keyword field', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'testKeyword',
|
||||
format: FieldFormatEnum.keyword,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(mapping.testKeyword).toEqual({
|
||||
type: 'keyword',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create correct mapping for number field', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'testNumber',
|
||||
format: FieldFormatEnum.number,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(mapping.testNumber).toEqual({
|
||||
type: 'integer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create correct mapping for select field', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'testSelect',
|
||||
format: FieldFormatEnum.select,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(mapping.testSelect).toEqual({
|
||||
type: 'keyword',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create correct mapping for multiSelect field', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'testMultiSelect',
|
||||
format: FieldFormatEnum.multiSelect,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(mapping.testMultiSelect).toEqual({
|
||||
type: 'keyword',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create correct mapping for date field', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'testDate',
|
||||
format: FieldFormatEnum.date,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(mapping.testDate).toEqual({
|
||||
type: 'date',
|
||||
format:
|
||||
'yyyy-MM-dd HH:mm:ss||yyyy-MM-dd HH:mm:ssZ||yyyy-MM-dd HH:mm:ssZZZZZ||yyyy-MM-dd||epoch_millis||strict_date_optional_time',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create correct mapping for images field', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'testImages',
|
||||
format: FieldFormatEnum.images,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(mapping.testImages).toEqual({
|
||||
type: 'text',
|
||||
analyzer: 'ngram_analyzer',
|
||||
search_analyzer: 'ngram_analyzer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should create correct mapping for aiField', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'testAiField',
|
||||
format: FieldFormatEnum.aiField,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(mapping.testAiField).toEqual({
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'text' },
|
||||
message: {
|
||||
type: 'text',
|
||||
analyzer: 'ngram_analyzer',
|
||||
search_analyzer: 'ngram_analyzer',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should create mapping for multiple fields', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'field1',
|
||||
format: FieldFormatEnum.text,
|
||||
} as FieldEntity,
|
||||
{
|
||||
key: 'field2',
|
||||
format: FieldFormatEnum.number,
|
||||
} as FieldEntity,
|
||||
{
|
||||
key: 'field3',
|
||||
format: FieldFormatEnum.keyword,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(Object.keys(mapping)).toHaveLength(3);
|
||||
expect(mapping.field1.type).toBe('text');
|
||||
expect(mapping.field2.type).toBe('integer');
|
||||
expect(mapping.field3.type).toBe('keyword');
|
||||
});
|
||||
|
||||
it('should return empty object for empty fields array', () => {
|
||||
const mapping = fieldService.fieldsToMapping([]);
|
||||
|
||||
expect(mapping).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMany', () => {
|
||||
it('creating many fields succeeds with valid inputs', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const fieldCount = faker.number.int({ min: 1, max: 10 });
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = Array.from({ length: fieldCount }).map(createFieldDto);
|
||||
const selectFieldCount = dto.fields.reduce(countSelect, 0);
|
||||
jest.spyOn(fieldRepo, 'save');
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
const fields = await fieldService.createMany(dto);
|
||||
|
||||
expect(fields.length).toBe(fieldCount + 4);
|
||||
|
||||
expect(optionRepo.save).toHaveBeenCalledTimes(selectFieldCount);
|
||||
});
|
||||
|
||||
it('creating many fields with OpenSearch enabled should call putMappings', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [createFieldDto()];
|
||||
|
||||
const mockFields = [createFieldDto({})] as FieldEntity[];
|
||||
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
|
||||
jest.spyOn(configService, 'get').mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(osRepository, 'putMappings')
|
||||
.mockResolvedValue({} as Indices_PutMapping_Response);
|
||||
|
||||
await fieldService.createMany(dto);
|
||||
|
||||
expect(osRepository.putMappings).toHaveBeenCalledWith({
|
||||
index: channelId.toString(),
|
||||
|
||||
mappings: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('creating many fields with OpenSearch disabled should not call putMappings', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [createFieldDto()];
|
||||
|
||||
const mockFields = [createFieldDto({})] as FieldEntity[];
|
||||
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
|
||||
jest.spyOn(configService, 'get').mockReturnValue(false);
|
||||
jest
|
||||
.spyOn(osRepository, 'putMappings')
|
||||
.mockResolvedValue({} as Indices_PutMapping_Response);
|
||||
|
||||
await fieldService.createMany(dto);
|
||||
|
||||
expect(osRepository.putMappings).not.toHaveBeenCalled();
|
||||
});
|
||||
it('creating many fields fails with duplicate names', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = Array.from({ length: 2 }).map(() =>
|
||||
createFieldDto({ name: 'duplicateName' }),
|
||||
);
|
||||
|
||||
await expect(fieldService.createMany(dto)).rejects.toThrow(
|
||||
FieldNameDuplicatedException,
|
||||
);
|
||||
});
|
||||
it('creating many fields fails with duplicate keys', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = Array.from({ length: 2 }).map(() =>
|
||||
createFieldDto({ key: 'duplicateKey' }),
|
||||
);
|
||||
|
||||
await expect(fieldService.createMany(dto)).rejects.toThrow(
|
||||
FieldKeyDuplicatedException,
|
||||
);
|
||||
});
|
||||
it('creating many fields fails with options in non-select format field', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = Array.from({ length: 1 }).map(() =>
|
||||
createFieldDto({
|
||||
format: FieldFormatEnum.text,
|
||||
options: [
|
||||
{ key: faker.string.sample(), name: faker.string.sample() },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(fieldService.createMany(dto)).rejects.toThrow(
|
||||
new BadRequestException('only select format field has options'),
|
||||
);
|
||||
});
|
||||
|
||||
it('creating many fields fails with invalid field key containing special characters', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [createFieldDto({ key: 'invalid-key!' })];
|
||||
|
||||
await expect(fieldService.createMany(dto)).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'field key only should contain alphanumeric and underscore',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('creating many fields fails with reserved field name', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [createFieldDto({ name: 'ID' })];
|
||||
|
||||
await expect(fieldService.createMany(dto)).rejects.toThrow(
|
||||
new BadRequestException('name is rejected'),
|
||||
);
|
||||
});
|
||||
|
||||
it('creating many fields fails with reserved field key', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [createFieldDto({ key: 'id' })];
|
||||
|
||||
await expect(fieldService.createMany(dto)).rejects.toThrow(
|
||||
new BadRequestException('key is rejected'),
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('replaceMany', () => {
|
||||
it('replacing many fields succeeds with valid inputs', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const updatingFieldDtos = Array.from({
|
||||
length: faker.number.int({ min: 1, max: 10 }),
|
||||
}).map(updateFieldDto);
|
||||
const creatingFieldDtos = Array.from({
|
||||
length: faker.number.int({ min: 1, max: 10 }),
|
||||
}).map(createFieldDto);
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [...creatingFieldDtos, ...updatingFieldDtos];
|
||||
jest
|
||||
.spyOn(fieldRepo, 'findBy')
|
||||
.mockResolvedValue(updatingFieldDtos as FieldEntity[]);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
jest.spyOn(fieldRepo, 'save');
|
||||
|
||||
await fieldService.replaceMany(dto);
|
||||
|
||||
expect(fieldRepo.save).toHaveBeenCalledTimes(
|
||||
updatingFieldDtos.length + creatingFieldDtos.length,
|
||||
);
|
||||
});
|
||||
|
||||
it('replacing many fields with OpenSearch enabled should call putMappings', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [updateFieldDto({})];
|
||||
|
||||
const mockFields = [createFieldDto({})] as FieldEntity[];
|
||||
jest
|
||||
.spyOn(fieldMySQLService, 'replaceMany')
|
||||
.mockResolvedValue(mockFields);
|
||||
jest.spyOn(configService, 'get').mockReturnValue(true);
|
||||
jest
|
||||
.spyOn(osRepository, 'putMappings')
|
||||
.mockResolvedValue({} as Indices_PutMapping_Response);
|
||||
|
||||
await fieldService.replaceMany(dto);
|
||||
|
||||
expect(osRepository.putMappings).toHaveBeenCalledWith({
|
||||
index: channelId.toString(),
|
||||
|
||||
mappings: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it('replacing many fields with OpenSearch disabled should not call putMappings', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [updateFieldDto({})];
|
||||
|
||||
const mockFields = [createFieldDto({})] as FieldEntity[];
|
||||
jest
|
||||
.spyOn(fieldMySQLService, 'replaceMany')
|
||||
.mockResolvedValue(mockFields);
|
||||
jest.spyOn(configService, 'get').mockReturnValue(false);
|
||||
jest
|
||||
.spyOn(osRepository, 'putMappings')
|
||||
.mockResolvedValue({} as Indices_PutMapping_Response);
|
||||
|
||||
await fieldService.replaceMany(dto);
|
||||
|
||||
expect(osRepository.putMappings).not.toHaveBeenCalled();
|
||||
});
|
||||
it('replacing many fields fails with duplicate names', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const updatingFieldDtos = Array.from({
|
||||
length: 2,
|
||||
}).map(() => updateFieldDto({ name: 'duplicateName' }));
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [...updatingFieldDtos];
|
||||
jest
|
||||
.spyOn(fieldRepo, 'findBy')
|
||||
.mockResolvedValue(updatingFieldDtos as FieldEntity[]);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
|
||||
FieldNameDuplicatedException,
|
||||
);
|
||||
});
|
||||
it('replacing many fields fails with duplicate keys', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const updatingFieldDtos = Array.from({
|
||||
length: 2,
|
||||
}).map(() => updateFieldDto({ key: 'duplicateKey' }));
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [...updatingFieldDtos];
|
||||
jest
|
||||
.spyOn(fieldRepo, 'findBy')
|
||||
.mockResolvedValue(updatingFieldDtos as FieldEntity[]);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
|
||||
FieldKeyDuplicatedException,
|
||||
);
|
||||
});
|
||||
it('replacing many fields fails with options in non-select format field', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const updatingFieldDtos = Array.from({
|
||||
length: 1,
|
||||
}).map(() =>
|
||||
updateFieldDto({
|
||||
format: FieldFormatEnum.text,
|
||||
options: [
|
||||
{ key: faker.string.sample(), name: faker.string.sample() },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [...updatingFieldDtos];
|
||||
jest
|
||||
.spyOn(fieldRepo, 'findBy')
|
||||
.mockResolvedValue(updatingFieldDtos as FieldEntity[]);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
|
||||
new BadRequestException('only select format field has options'),
|
||||
);
|
||||
});
|
||||
it('replacing many fields fails with a nonexistent field', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const updatingFieldDtos = Array.from({
|
||||
length: faker.number.int({ min: 1, max: 10 }),
|
||||
}).map(updateFieldDto);
|
||||
const creatingFieldDtos = Array.from({
|
||||
length: faker.number.int({ min: 1, max: 10 }),
|
||||
}).map(createFieldDto);
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [...creatingFieldDtos, ...updatingFieldDtos];
|
||||
jest
|
||||
.spyOn(fieldRepo, 'findBy')
|
||||
.mockResolvedValue(updatingFieldDtos.splice(1) as FieldEntity[]);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
|
||||
new BadRequestException('field must be included'),
|
||||
);
|
||||
});
|
||||
it('replacing many fields fails with a format change', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const updatingFieldDtos = Array.from({
|
||||
length: faker.number.int({ min: 1, max: 10 }),
|
||||
}).map(() => updateFieldDto({ format: FieldFormatEnum.keyword }));
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = JSON.parse(
|
||||
JSON.stringify(updatingFieldDtos),
|
||||
) as ReplaceFieldDto[];
|
||||
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue(
|
||||
updatingFieldDtos.map((field) => {
|
||||
field.format = FieldFormatEnum.text;
|
||||
return field;
|
||||
}) as FieldEntity[],
|
||||
);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
|
||||
new BadRequestException('field format cannot be changed'),
|
||||
);
|
||||
});
|
||||
it('replacing many fields fails with a key change', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const updatingFieldDtos = Array.from({
|
||||
length: faker.number.int({ min: 1, max: 10 }),
|
||||
}).map(updateFieldDto);
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = JSON.parse(
|
||||
JSON.stringify(updatingFieldDtos),
|
||||
) as ReplaceFieldDto[];
|
||||
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue(
|
||||
updatingFieldDtos.map((field) => {
|
||||
field.key = faker.string.sample();
|
||||
return field;
|
||||
}) as FieldEntity[],
|
||||
);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
|
||||
new BadRequestException('field key cannot be changed'),
|
||||
);
|
||||
});
|
||||
|
||||
it('replacing many fields fails with invalid field key containing special characters', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const creatingFieldDtos = [createFieldDto({ key: 'invalid-key!' })];
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [...creatingFieldDtos];
|
||||
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue([]);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'field key only should contain alphanumeric and underscore',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('replacing many fields fails with reserved field name in creating fields', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const creatingFieldDtos = [createFieldDto({ name: 'ID' })];
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [...creatingFieldDtos];
|
||||
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue([]);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
|
||||
new BadRequestException('name is rejected'),
|
||||
);
|
||||
});
|
||||
|
||||
it('replacing many fields fails with reserved field key in creating fields', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const creatingFieldDtos = [createFieldDto({ key: 'id' })];
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [...creatingFieldDtos];
|
||||
jest.spyOn(fieldRepo, 'findBy').mockResolvedValue([]);
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
|
||||
await expect(fieldService.replaceMany(dto)).rejects.toThrow(
|
||||
new BadRequestException('key is rejected'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByChannelId', () => {
|
||||
it('should return fields for given channel id', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const mockFields = [createFieldDto(), createFieldDto()] as FieldEntity[];
|
||||
|
||||
jest
|
||||
.spyOn(fieldMySQLService, 'findByChannelId')
|
||||
.mockResolvedValue(mockFields);
|
||||
|
||||
const result = await fieldService.findByChannelId({ channelId });
|
||||
|
||||
expect(fieldMySQLService.findByChannelId).toHaveBeenCalledWith({
|
||||
channelId,
|
||||
});
|
||||
expect(result).toEqual(mockFields);
|
||||
});
|
||||
|
||||
it('should return empty array when no fields found', async () => {
|
||||
const channelId = faker.number.int();
|
||||
|
||||
jest.spyOn(fieldMySQLService, 'findByChannelId').mockResolvedValue([]);
|
||||
|
||||
const result = await fieldService.findByChannelId({ channelId });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIds', () => {
|
||||
it('should return fields for given ids', async () => {
|
||||
const ids = [faker.number.int(), faker.number.int()];
|
||||
const mockFields = [createFieldDto(), createFieldDto()] as FieldEntity[];
|
||||
|
||||
jest.spyOn(fieldMySQLService, 'findByIds').mockResolvedValue(mockFields);
|
||||
|
||||
const result = await fieldService.findByIds(ids);
|
||||
|
||||
expect(fieldMySQLService.findByIds).toHaveBeenCalledWith(ids);
|
||||
expect(result).toEqual(mockFields);
|
||||
});
|
||||
|
||||
it('should return empty array when no fields found', async () => {
|
||||
const ids = [faker.number.int()];
|
||||
|
||||
jest.spyOn(fieldMySQLService, 'findByIds').mockResolvedValue([]);
|
||||
|
||||
const result = await fieldService.findByIds(ids);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty ids array', async () => {
|
||||
jest.spyOn(fieldMySQLService, 'findByIds').mockResolvedValue([]);
|
||||
|
||||
const result = await fieldService.findByIds([]);
|
||||
|
||||
expect(fieldMySQLService.findByIds).toHaveBeenCalledWith([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('createMany should handle empty fields array', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [];
|
||||
|
||||
const mockFields = [] as FieldEntity[];
|
||||
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
|
||||
jest.spyOn(configService, 'get').mockReturnValue(false);
|
||||
|
||||
const result = await fieldService.createMany(dto);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(fieldMySQLService.createMany).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('replaceMany should handle empty fields array', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [];
|
||||
|
||||
const mockFields = [] as FieldEntity[];
|
||||
jest
|
||||
.spyOn(fieldMySQLService, 'replaceMany')
|
||||
.mockResolvedValue(mockFields);
|
||||
jest.spyOn(configService, 'get').mockReturnValue(false);
|
||||
|
||||
await fieldService.replaceMany(dto);
|
||||
|
||||
expect(fieldMySQLService.replaceMany).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
|
||||
it('createMany should handle null channelId', async () => {
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = null as unknown as number;
|
||||
dto.fields = [createFieldDto()];
|
||||
|
||||
const mockFields = [createFieldDto({})] as FieldEntity[];
|
||||
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
|
||||
jest.spyOn(configService, 'get').mockReturnValue(false);
|
||||
|
||||
const result = await fieldService.createMany(dto);
|
||||
|
||||
expect(result).toEqual(mockFields);
|
||||
});
|
||||
|
||||
it('fieldsToMapping should handle fields with null/undefined properties', () => {
|
||||
const fields = [
|
||||
{
|
||||
key: 'testField',
|
||||
format: FieldFormatEnum.text,
|
||||
} as FieldEntity,
|
||||
{
|
||||
key: '',
|
||||
format: FieldFormatEnum.keyword,
|
||||
} as FieldEntity,
|
||||
];
|
||||
|
||||
const mapping = fieldService.fieldsToMapping(fields);
|
||||
|
||||
expect(mapping.testField).toBeDefined();
|
||||
expect(mapping['']).toBeDefined();
|
||||
});
|
||||
|
||||
it('createMany should handle fields with empty options array', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const dto = new CreateManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [
|
||||
createFieldDto({
|
||||
format: FieldFormatEnum.select,
|
||||
options: [],
|
||||
}),
|
||||
];
|
||||
|
||||
const mockFields = [createFieldDto({})] as FieldEntity[];
|
||||
jest.spyOn(fieldMySQLService, 'createMany').mockResolvedValue(mockFields);
|
||||
jest.spyOn(configService, 'get').mockReturnValue(false);
|
||||
|
||||
const result = await fieldService.createMany(dto);
|
||||
|
||||
expect(result).toEqual(mockFields);
|
||||
});
|
||||
|
||||
it('replaceMany should handle mixed creating and updating fields', async () => {
|
||||
const channelId = faker.number.int();
|
||||
const creatingFieldDtos = [createFieldDto({})];
|
||||
const updatingFieldDtos = [updateFieldDto({})];
|
||||
const dto = new ReplaceManyFieldsDto();
|
||||
dto.channelId = channelId;
|
||||
dto.fields = [...creatingFieldDtos, ...updatingFieldDtos];
|
||||
|
||||
const mockFields = [createFieldDto({})] as FieldEntity[];
|
||||
jest
|
||||
.spyOn(fieldMySQLService, 'replaceMany')
|
||||
.mockResolvedValue(mockFields);
|
||||
jest.spyOn(configService, 'get').mockReturnValue(false);
|
||||
|
||||
await fieldService.replaceMany(dto);
|
||||
|
||||
expect(fieldMySQLService.replaceMany).toHaveBeenCalledWith(dto);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
DateProperty,
|
||||
Property,
|
||||
TextProperty,
|
||||
} from '@opensearch-project/opensearch/api/_types/_common.mapping';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
|
||||
import { FieldFormatEnum } from '@/common/enums';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { CreateManyFieldsDto, ReplaceManyFieldsDto } from './dtos';
|
||||
import type { FieldEntity } from './field.entity';
|
||||
import { FieldMySQLService } from './field.mysql.service';
|
||||
|
||||
export const FIELD_TYPES_TO_MAPPING_TYPES: Record<FieldFormatEnum, string> = {
|
||||
text: 'text',
|
||||
keyword: 'keyword',
|
||||
number: 'integer',
|
||||
select: 'keyword',
|
||||
multiSelect: 'keyword',
|
||||
date: 'date',
|
||||
images: 'text',
|
||||
aiField: 'text',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FieldService {
|
||||
constructor(
|
||||
private readonly fieldMySQLService: FieldMySQLService,
|
||||
private readonly osRepository: OpensearchRepository,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
fieldsToMapping(fields: FieldEntity[]) {
|
||||
return fields.reduce((mapping: Record<string, Property>, field) => {
|
||||
let property: Property;
|
||||
|
||||
if (
|
||||
field.format === FieldFormatEnum.text ||
|
||||
field.format === FieldFormatEnum.images
|
||||
) {
|
||||
property = {
|
||||
type: FIELD_TYPES_TO_MAPPING_TYPES[field.format],
|
||||
analyzer: 'ngram_analyzer',
|
||||
search_analyzer: 'ngram_analyzer',
|
||||
} as TextProperty;
|
||||
} else if (field.format === FieldFormatEnum.aiField) {
|
||||
property = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'text' },
|
||||
message: {
|
||||
type: 'text',
|
||||
analyzer: 'ngram_analyzer',
|
||||
search_analyzer: 'ngram_analyzer',
|
||||
},
|
||||
},
|
||||
} as Property;
|
||||
} else if (field.format === FieldFormatEnum.date) {
|
||||
property = {
|
||||
type: FIELD_TYPES_TO_MAPPING_TYPES[field.format],
|
||||
format: `yyyy-MM-dd HH:mm:ss||yyyy-MM-dd HH:mm:ssZ||yyyy-MM-dd HH:mm:ssZZZZZ||yyyy-MM-dd||epoch_millis||strict_date_optional_time`,
|
||||
} as DateProperty;
|
||||
} else {
|
||||
property = {
|
||||
type: FIELD_TYPES_TO_MAPPING_TYPES[field.format],
|
||||
} as Property;
|
||||
}
|
||||
|
||||
return Object.assign(mapping, {
|
||||
[field.key]: property,
|
||||
});
|
||||
}, {});
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async createMany(dto: CreateManyFieldsDto) {
|
||||
const fields = await this.fieldMySQLService.createMany(dto);
|
||||
|
||||
if (this.configService.get('opensearch.use')) {
|
||||
await this.osRepository.putMappings({
|
||||
index: dto.channelId.toString(),
|
||||
mappings: this.fieldsToMapping(fields),
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
async findByChannelId(dto: { channelId: number }) {
|
||||
return this.fieldMySQLService.findByChannelId(dto);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async replaceMany(dto: ReplaceManyFieldsDto) {
|
||||
const createdFields = await this.fieldMySQLService.replaceMany(dto);
|
||||
|
||||
if (this.configService.get('opensearch.use')) {
|
||||
await this.osRepository.putMappings({
|
||||
index: dto.channelId.toString(),
|
||||
mappings: this.fieldsToMapping(createdFields),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findByIds(ids: number[]) {
|
||||
return this.fieldMySQLService.findByIds(ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class CreateManyOptionsDto {
|
||||
fieldId: number;
|
||||
options: { name: string; key: string }[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class CreateOptionDto {
|
||||
fieldId: number;
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { CreateManyOptionsDto } from './create-many-options.dto';
|
||||
export { ReplaceManyOptionsDto } from './replace-select-options.dto';
|
||||
export { CreateOptionDto } from './create-option.dto';
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class ReplaceManyOptionsDto {
|
||||
fieldId: number;
|
||||
options: { id?: number; name: string; key: string }[] | undefined;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateOptionRequestDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(20)
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(20)
|
||||
key: string;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { CreateOptionRequestDto } from './create-option-request.dto';
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, plainToInstance } from 'class-transformer';
|
||||
|
||||
export class CreateOptionResponseDto {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
id: number;
|
||||
public static transform(params: any): CreateOptionResponseDto {
|
||||
return plainToInstance(CreateOptionResponseDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, plainToInstance } from 'class-transformer';
|
||||
|
||||
export class FindOptionByFieldIdResponseDto {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
id: number;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
name: string;
|
||||
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
key: string;
|
||||
|
||||
public static transform(params: any): FindOptionByFieldIdResponseDto {
|
||||
return plainToInstance(FindOptionByFieldIdResponseDto, params, {
|
||||
excludeExtraneousValues: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { FindOptionByFieldIdResponseDto } from './find-option-by-field-id-response.dto';
|
||||
export { CreateOptionResponseDto } from './create-option-response.dto';
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { OptionNameDuplicatedException } from './option-name-duplicated.exception';
|
||||
export { OptionKeyDuplicatedException } from './option-key-duplicated.exception';
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class OptionKeyDuplicatedException extends BadRequestException {
|
||||
constructor() {
|
||||
super({
|
||||
code: ErrorCode.Option.OptionKeyDuplicated,
|
||||
message: 'option key is duplicated',
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class OptionNameDuplicatedException extends BadRequestException {
|
||||
constructor() {
|
||||
super({
|
||||
code: ErrorCode.Option.OptionNameDuplicated,
|
||||
message: 'option name is duplicated',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { getMockProvider, MockDataSource } from '@/test-utils/util-functions';
|
||||
import { CreateOptionRequestDto } from './dtos/requests';
|
||||
import {
|
||||
OptionKeyDuplicatedException,
|
||||
OptionNameDuplicatedException,
|
||||
} from './exceptions';
|
||||
import { OptionController } from './option.controller';
|
||||
import type { OptionEntity } from './option.entity';
|
||||
import { OptionService } from './option.service';
|
||||
|
||||
const MockSelectOptionService = {
|
||||
findByFieldId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
};
|
||||
|
||||
describe('SelectOptionController', () => {
|
||||
let optionController: OptionController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [OptionController],
|
||||
providers: [
|
||||
getMockProvider(OptionService, MockSelectOptionService),
|
||||
getMockProvider(DataSource, MockDataSource),
|
||||
],
|
||||
}).compile();
|
||||
|
||||
optionController = module.get<OptionController>(OptionController);
|
||||
});
|
||||
|
||||
describe('getOptions', () => {
|
||||
it('should return transformed options for valid fieldId', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const mockOptions = [
|
||||
{ id: 1, name: 'Option 1', key: 'option1', fieldId },
|
||||
{ id: 2, name: 'Option 2', key: 'option2', fieldId },
|
||||
] as unknown as OptionEntity[];
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'findByFieldId')
|
||||
.mockResolvedValue(mockOptions);
|
||||
|
||||
const result = await optionController.getOptions(fieldId);
|
||||
|
||||
expect(MockSelectOptionService.findByFieldId).toHaveBeenCalledWith({
|
||||
fieldId,
|
||||
});
|
||||
expect(MockSelectOptionService.findByFieldId).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toHaveProperty('id', 1);
|
||||
expect(result[0]).toHaveProperty('name', 'Option 1');
|
||||
expect(result[0]).toHaveProperty('key', 'option1');
|
||||
});
|
||||
|
||||
it('should return empty array when no options found', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'findByFieldId')
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await optionController.getOptions(fieldId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(MockSelectOptionService.findByFieldId).toHaveBeenCalledWith({
|
||||
fieldId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle service errors', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const error = new Error('Database connection failed');
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'findByFieldId')
|
||||
.mockRejectedValue(error);
|
||||
|
||||
await expect(optionController.getOptions(fieldId)).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
describe('createOption', () => {
|
||||
it('should create option successfully with valid data', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateOptionRequestDto();
|
||||
dto.name = faker.string.alphanumeric(10);
|
||||
dto.key = faker.string.alphanumeric(10);
|
||||
|
||||
const mockCreatedOption = {
|
||||
id: faker.number.int(),
|
||||
name: dto.name,
|
||||
key: dto.key,
|
||||
fieldId,
|
||||
} as unknown as OptionEntity;
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'create')
|
||||
.mockResolvedValue(mockCreatedOption);
|
||||
|
||||
const result = await optionController.createOption(fieldId, dto);
|
||||
|
||||
expect(MockSelectOptionService.create).toHaveBeenCalledWith({
|
||||
fieldId,
|
||||
name: dto.name,
|
||||
key: dto.key,
|
||||
});
|
||||
expect(MockSelectOptionService.create).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveProperty('id', mockCreatedOption.id);
|
||||
});
|
||||
|
||||
it('should throw OptionNameDuplicatedException when name is duplicated', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateOptionRequestDto();
|
||||
dto.name = faker.string.alphanumeric(10);
|
||||
dto.key = faker.string.alphanumeric(10);
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'create')
|
||||
.mockRejectedValue(new OptionNameDuplicatedException());
|
||||
|
||||
await expect(optionController.createOption(fieldId, dto)).rejects.toThrow(
|
||||
OptionNameDuplicatedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw OptionKeyDuplicatedException when key is duplicated', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateOptionRequestDto();
|
||||
dto.name = faker.string.alphanumeric(10);
|
||||
dto.key = faker.string.alphanumeric(10);
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'create')
|
||||
.mockRejectedValue(new OptionKeyDuplicatedException());
|
||||
|
||||
await expect(optionController.createOption(fieldId, dto)).rejects.toThrow(
|
||||
OptionKeyDuplicatedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle service errors', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateOptionRequestDto();
|
||||
dto.name = faker.string.alphanumeric(10);
|
||||
dto.key = faker.string.alphanumeric(10);
|
||||
|
||||
const error = new Error('Database connection failed');
|
||||
jest.spyOn(MockSelectOptionService, 'create').mockRejectedValue(error);
|
||||
|
||||
await expect(optionController.createOption(fieldId, dto)).rejects.toThrow(
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty name and key', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateOptionRequestDto();
|
||||
dto.name = '';
|
||||
dto.key = '';
|
||||
|
||||
const mockCreatedOption = {
|
||||
id: faker.number.int(),
|
||||
name: '',
|
||||
key: '',
|
||||
fieldId,
|
||||
} as unknown as OptionEntity;
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'create')
|
||||
.mockResolvedValue(mockCreatedOption);
|
||||
|
||||
const result = await optionController.createOption(fieldId, dto);
|
||||
|
||||
expect(result).toHaveProperty('id', mockCreatedOption.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parameter validation', () => {
|
||||
it('should handle invalid fieldId parameter', async () => {
|
||||
const invalidFieldId = 'invalid' as unknown as number;
|
||||
|
||||
await expect(
|
||||
optionController.getOptions(invalidFieldId),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should handle negative fieldId', async () => {
|
||||
const negativeFieldId = -1;
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'findByFieldId')
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await optionController.getOptions(negativeFieldId);
|
||||
|
||||
expect(MockSelectOptionService.findByFieldId).toHaveBeenCalledWith({
|
||||
fieldId: negativeFieldId,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle zero fieldId', async () => {
|
||||
const zeroFieldId = 0;
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'findByFieldId')
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await optionController.getOptions(zeroFieldId);
|
||||
|
||||
expect(MockSelectOptionService.findByFieldId).toHaveBeenCalledWith({
|
||||
fieldId: zeroFieldId,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle very large fieldId', async () => {
|
||||
const largeFieldId = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'findByFieldId')
|
||||
.mockResolvedValue([]);
|
||||
|
||||
const result = await optionController.getOptions(largeFieldId);
|
||||
|
||||
expect(MockSelectOptionService.findByFieldId).toHaveBeenCalledWith({
|
||||
fieldId: largeFieldId,
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle null DTO properties', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateOptionRequestDto();
|
||||
dto.name = null as unknown as string;
|
||||
dto.key = null as unknown as string;
|
||||
|
||||
const mockCreatedOption = {
|
||||
id: faker.number.int(),
|
||||
name: null,
|
||||
key: null,
|
||||
fieldId,
|
||||
} as unknown as OptionEntity;
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'create')
|
||||
.mockResolvedValue(mockCreatedOption);
|
||||
|
||||
const result = await optionController.createOption(fieldId, dto);
|
||||
|
||||
expect(result).toHaveProperty('id', mockCreatedOption.id);
|
||||
});
|
||||
|
||||
it('should handle undefined DTO properties', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateOptionRequestDto();
|
||||
dto.name = undefined as unknown as string;
|
||||
dto.key = undefined as unknown as string;
|
||||
|
||||
const mockCreatedOption = {
|
||||
id: faker.number.int(),
|
||||
name: undefined,
|
||||
key: undefined,
|
||||
fieldId,
|
||||
} as unknown as OptionEntity;
|
||||
|
||||
jest
|
||||
.spyOn(MockSelectOptionService, 'create')
|
||||
.mockResolvedValue(mockCreatedOption);
|
||||
|
||||
const result = await optionController.createOption(fieldId, dto);
|
||||
|
||||
expect(result).toHaveProperty('id', mockCreatedOption.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ApiCreatedResponse, ApiOkResponse } from '@nestjs/swagger';
|
||||
|
||||
import { PermissionEnum } from '@/domains/admin/project/role/permission.enum';
|
||||
import { RequirePermission } from '@/domains/admin/project/role/require-permission.decorator';
|
||||
import { CreateOptionRequestDto } from './dtos/requests';
|
||||
import {
|
||||
CreateOptionResponseDto,
|
||||
FindOptionByFieldIdResponseDto,
|
||||
} from './dtos/responses';
|
||||
import { OptionService } from './option.service';
|
||||
|
||||
@Controller('/admin/fields/:fieldId/options')
|
||||
export class OptionController {
|
||||
constructor(private readonly optionService: OptionService) {}
|
||||
|
||||
@ApiOkResponse({ type: [FindOptionByFieldIdResponseDto] })
|
||||
@Get()
|
||||
async getOptions(@Param('fieldId', ParseIntPipe) fieldId: number) {
|
||||
return (await this.optionService.findByFieldId({ fieldId })).map((v) =>
|
||||
FindOptionByFieldIdResponseDto.transform(v),
|
||||
);
|
||||
}
|
||||
|
||||
@ApiCreatedResponse({ type: CreateOptionResponseDto })
|
||||
@RequirePermission(PermissionEnum.feedback_update)
|
||||
@Post()
|
||||
async createOption(
|
||||
@Param('fieldId', ParseIntPipe) fieldId: number,
|
||||
@Body() body: CreateOptionRequestDto,
|
||||
) {
|
||||
return CreateOptionResponseDto.transform(
|
||||
await this.optionService.create({ fieldId, ...body }),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Column, Entity, ManyToOne, Relation } from 'typeorm';
|
||||
|
||||
import { CommonEntity } from '@/common/entities';
|
||||
import { FieldEntity } from '../field/field.entity';
|
||||
|
||||
@Entity('options')
|
||||
export class OptionEntity extends CommonEntity {
|
||||
@ManyToOne(() => FieldEntity, (field) => field.options, {
|
||||
onDelete: 'CASCADE',
|
||||
orphanedRowAction: 'delete',
|
||||
})
|
||||
field: Relation<FieldEntity>;
|
||||
|
||||
@Column('varchar')
|
||||
name: string;
|
||||
|
||||
@Column('varchar')
|
||||
key: string;
|
||||
|
||||
static from({
|
||||
fieldId,
|
||||
name,
|
||||
key,
|
||||
}: {
|
||||
fieldId: number;
|
||||
name: string;
|
||||
key: string;
|
||||
}) {
|
||||
const option = new OptionEntity();
|
||||
option.field = new FieldEntity();
|
||||
option.field.id = fieldId;
|
||||
option.name = name;
|
||||
option.key = key;
|
||||
|
||||
return option;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { OptionController } from './option.controller';
|
||||
import { OptionEntity } from './option.entity';
|
||||
import { OptionService } from './option.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([OptionEntity])],
|
||||
providers: [OptionService],
|
||||
controllers: [OptionController],
|
||||
exports: [OptionService],
|
||||
})
|
||||
export class OptionModule {}
|
||||
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import type { Repository } from 'typeorm';
|
||||
|
||||
import { optionFixture } from '@/test-utils/fixtures';
|
||||
import { TestConfig } from '@/test-utils/util-functions';
|
||||
import { OptionServiceProviders } from '../../../../test-utils/providers/option.service.providers';
|
||||
import {
|
||||
CreateManyOptionsDto,
|
||||
CreateOptionDto,
|
||||
ReplaceManyOptionsDto,
|
||||
} from './dtos';
|
||||
import {
|
||||
OptionKeyDuplicatedException,
|
||||
OptionNameDuplicatedException,
|
||||
} from './exceptions';
|
||||
import { OptionEntity } from './option.entity';
|
||||
import { OptionService } from './option.service';
|
||||
|
||||
describe('Option Test suite', () => {
|
||||
let optionService: OptionService;
|
||||
let optionRepo: Repository<OptionEntity>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
imports: [TestConfig],
|
||||
providers: OptionServiceProviders,
|
||||
}).compile();
|
||||
|
||||
optionService = module.get<OptionService>(OptionService);
|
||||
optionRepo = module.get(getRepositoryToken(OptionEntity));
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creating an option succeeds with a new valid input', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateOptionDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.key = faker.string.sample();
|
||||
dto.name = faker.string.sample();
|
||||
jest.spyOn(optionRepo, 'findBy').mockResolvedValue([
|
||||
{
|
||||
key: faker.string.sample(),
|
||||
name: faker.string.sample(),
|
||||
},
|
||||
] as OptionEntity[]);
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
const option = await optionService.create(dto);
|
||||
|
||||
expect(option.key).toBe(dto.key);
|
||||
expect(option.name).toBe(dto.name);
|
||||
});
|
||||
it('creating an option succeeds with an inactive input', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const optionId = faker.number.int();
|
||||
const dto = new CreateOptionDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.key = faker.string.sample();
|
||||
dto.name = faker.string.sample();
|
||||
jest.spyOn(optionRepo, 'findBy').mockResolvedValue([
|
||||
{
|
||||
id: optionId,
|
||||
key: 'deleted_' + dto.key,
|
||||
name: dto.name,
|
||||
},
|
||||
] as OptionEntity[]);
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
const option = await optionService.create(dto);
|
||||
|
||||
expect(option.key).toBe(dto.key);
|
||||
expect(option.name).toBe(dto.name);
|
||||
});
|
||||
it('creating an option fails with a duplicate name', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const duplicateName = optionFixture.name;
|
||||
const dto = new CreateOptionDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.key = faker.string.sample();
|
||||
dto.name = duplicateName;
|
||||
|
||||
await expect(optionService.create(dto)).rejects.toThrow(
|
||||
OptionNameDuplicatedException,
|
||||
);
|
||||
});
|
||||
it('creating an option fails with a duplicate key', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const duplicateKey = optionFixture.key;
|
||||
const dto = new CreateOptionDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.key = duplicateKey;
|
||||
dto.name = faker.string.sample();
|
||||
|
||||
await expect(optionService.create(dto)).rejects.toThrow(
|
||||
OptionKeyDuplicatedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMany', () => {
|
||||
it('creating many options succeeds with valid inputs', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const optionLength = faker.number.int({ min: 1, max: 10 });
|
||||
const dto = new CreateManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = Array.from({
|
||||
length: optionLength,
|
||||
}).map(() => ({
|
||||
key: faker.string.sample(),
|
||||
name: faker.string.sample(),
|
||||
}));
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
const options = await optionService.createMany(dto);
|
||||
|
||||
for (let i = 0; i < optionLength; i++) {
|
||||
expect(options[i].key).toBe(dto.options[i].key);
|
||||
expect(options[i].name).toBe(dto.options[i].name);
|
||||
}
|
||||
});
|
||||
it('creating many options fails with duplicate names', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = Array.from({
|
||||
length: faker.number.int({ min: 2, max: 10 }),
|
||||
}).map(() => ({
|
||||
key: faker.string.sample(),
|
||||
name: 'duplicateName',
|
||||
}));
|
||||
|
||||
await expect(optionService.createMany(dto)).rejects.toThrow(
|
||||
OptionNameDuplicatedException,
|
||||
);
|
||||
});
|
||||
it('creating many options fails with duplicate keys', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = Array.from({
|
||||
length: faker.number.int({ min: 2, max: 10 }),
|
||||
}).map(() => ({
|
||||
key: 'duplicateKey',
|
||||
name: faker.string.sample(),
|
||||
}));
|
||||
|
||||
await expect(optionService.createMany(dto)).rejects.toThrow(
|
||||
OptionKeyDuplicatedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe('replaceMany', () => {
|
||||
it('replacing many options succeeds with valid inputs', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const length = faker.number.int({ min: 1, max: 10 });
|
||||
const dto = new ReplaceManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = Array.from({
|
||||
length,
|
||||
}).map(() => ({
|
||||
id: faker.number.int(),
|
||||
key: faker.string.sample(),
|
||||
name: faker.string.sample(),
|
||||
}));
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue(
|
||||
Array.from({
|
||||
length: faker.number.int({ min: 1, max: 10 }),
|
||||
}).map(() => ({
|
||||
id: faker.number.int(),
|
||||
key: faker.string.sample(),
|
||||
name: faker.string.sample(),
|
||||
deletedAt: null,
|
||||
})) as unknown as OptionEntity[],
|
||||
);
|
||||
jest.spyOn(optionRepo, 'query');
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
await optionService.replaceMany(dto);
|
||||
|
||||
expect(optionRepo.save).toHaveBeenCalledTimes(length);
|
||||
});
|
||||
|
||||
it('replacing many options fails with duplicate names', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new ReplaceManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = Array.from({
|
||||
length: faker.number.int({ min: 2, max: 10 }),
|
||||
}).map(() => ({
|
||||
id: faker.number.int(),
|
||||
key: faker.string.sample(),
|
||||
name: 'duplicateName',
|
||||
}));
|
||||
|
||||
await expect(optionService.replaceMany(dto)).rejects.toThrow(
|
||||
OptionNameDuplicatedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('replacing many options fails with duplicate keys', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new ReplaceManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = Array.from({
|
||||
length: faker.number.int({ min: 2, max: 10 }),
|
||||
}).map(() => ({
|
||||
id: faker.number.int(),
|
||||
key: 'duplicateKey',
|
||||
name: faker.string.sample(),
|
||||
}));
|
||||
|
||||
await expect(optionService.replaceMany(dto)).rejects.toThrow(
|
||||
OptionKeyDuplicatedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('replacing many options succeeds with empty options array', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new ReplaceManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = [];
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
jest.spyOn(optionRepo, 'query');
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
await optionService.replaceMany(dto);
|
||||
|
||||
expect(optionRepo.save).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('replacing many options handles inactive options correctly', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const optionId = faker.number.int();
|
||||
const key = faker.string.sample();
|
||||
const name = faker.string.sample();
|
||||
const dto = new ReplaceManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = [{ id: optionId, key, name }];
|
||||
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([
|
||||
{
|
||||
id: optionId,
|
||||
key: 'deleted_' + key,
|
||||
name,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
] as unknown as OptionEntity[]);
|
||||
jest.spyOn(optionRepo, 'query');
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
await optionService.replaceMany(dto);
|
||||
|
||||
expect(optionRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('replacing many options deletes unused options', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const existingOptionId = faker.number.int();
|
||||
const dto = new ReplaceManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = [
|
||||
{
|
||||
id: faker.number.int(),
|
||||
key: faker.string.sample(),
|
||||
name: faker.string.sample(),
|
||||
},
|
||||
];
|
||||
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([
|
||||
{
|
||||
id: existingOptionId,
|
||||
key: faker.string.sample(),
|
||||
name: faker.string.sample(),
|
||||
deletedAt: null,
|
||||
},
|
||||
] as unknown as OptionEntity[]);
|
||||
jest.spyOn(optionRepo, 'query');
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
await optionService.replaceMany(dto);
|
||||
|
||||
expect(optionRepo.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE'),
|
||||
expect.arrayContaining([expect.any(String), [existingOptionId]]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByFieldId', () => {
|
||||
it('finding options by field id succeeds', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const mockOptions = Array.from({
|
||||
length: faker.number.int({ min: 1, max: 10 }),
|
||||
}).map(() => ({
|
||||
id: faker.number.int(),
|
||||
key: faker.string.sample(),
|
||||
name: faker.string.sample(),
|
||||
fieldId,
|
||||
})) as unknown as OptionEntity[];
|
||||
|
||||
jest.spyOn(optionRepo, 'findBy').mockResolvedValue(mockOptions);
|
||||
|
||||
const result = await optionService.findByFieldId({ fieldId });
|
||||
|
||||
expect(optionRepo.findBy).toHaveBeenCalledWith({
|
||||
field: { id: fieldId },
|
||||
});
|
||||
expect(result).toEqual(mockOptions);
|
||||
});
|
||||
|
||||
it('finding options by field id returns empty array when no options exist', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
jest.spyOn(optionRepo, 'findBy').mockResolvedValue([]);
|
||||
|
||||
const result = await optionService.findByFieldId({ fieldId });
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create edge cases', () => {
|
||||
it('creating an option with empty key succeeds', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const name = faker.string.sample();
|
||||
const dto = new CreateOptionDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.key = '';
|
||||
dto.name = name;
|
||||
jest.spyOn(optionRepo, 'findBy').mockResolvedValue([]);
|
||||
jest
|
||||
.spyOn(optionRepo, 'save')
|
||||
.mockImplementation(() => Promise.resolve({ key: '', name } as any));
|
||||
|
||||
const result = await optionService.create(dto);
|
||||
|
||||
expect(result.key).toBe('');
|
||||
});
|
||||
|
||||
it('creating an option with empty name succeeds', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const key = faker.string.sample();
|
||||
const dto = new CreateOptionDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.key = key;
|
||||
dto.name = '';
|
||||
jest.spyOn(optionRepo, 'findBy').mockResolvedValue([]);
|
||||
jest
|
||||
.spyOn(optionRepo, 'save')
|
||||
.mockImplementation(() => Promise.resolve({ key, name: '' } as any));
|
||||
|
||||
const result = await optionService.create(dto);
|
||||
|
||||
expect(result.name).toBe('');
|
||||
});
|
||||
|
||||
it('creating an option with null fieldId succeeds', async () => {
|
||||
const dto = new CreateOptionDto();
|
||||
|
||||
dto.fieldId = null as any;
|
||||
dto.key = faker.string.sample();
|
||||
dto.name = faker.string.sample();
|
||||
jest.spyOn(optionRepo, 'findBy').mockResolvedValue([]);
|
||||
jest
|
||||
.spyOn(optionRepo, 'save')
|
||||
.mockImplementation(() =>
|
||||
Promise.resolve({ key: '', name: faker.string.sample() } as any),
|
||||
);
|
||||
|
||||
const result = await optionService.create(dto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMany edge cases', () => {
|
||||
it('creating many options with empty array succeeds', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = [];
|
||||
jest
|
||||
.spyOn(optionRepo, 'save')
|
||||
.mockImplementation(() => Promise.resolve([] as any));
|
||||
|
||||
const result = await optionService.createMany(dto);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(optionRepo.save).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('creating many options with single option succeeds', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new CreateManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
dto.options = [
|
||||
{ key: faker.string.sample(), name: faker.string.sample() },
|
||||
];
|
||||
jest
|
||||
.spyOn(optionRepo, 'save')
|
||||
.mockImplementation(() => Promise.resolve([{}] as any));
|
||||
|
||||
const result = await optionService.createMany(dto);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceMany edge cases', () => {
|
||||
it('replacing many options with null options array succeeds', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new ReplaceManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
|
||||
dto.options = null as any;
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
jest.spyOn(optionRepo, 'query');
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
await optionService.replaceMany(dto);
|
||||
|
||||
expect(optionRepo.save).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('replacing many options with undefined options array succeeds', async () => {
|
||||
const fieldId = faker.number.int();
|
||||
const dto = new ReplaceManyOptionsDto();
|
||||
dto.fieldId = fieldId;
|
||||
|
||||
dto.options = undefined as any;
|
||||
jest.spyOn(optionRepo, 'find').mockResolvedValue([]);
|
||||
jest.spyOn(optionRepo, 'query');
|
||||
jest.spyOn(optionRepo, 'save');
|
||||
|
||||
await optionService.replaceMany(dto);
|
||||
|
||||
expect(optionRepo.save).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
|
||||
import { validateUnique } from '@/utils/validate-unique';
|
||||
import {
|
||||
CreateManyOptionsDto,
|
||||
CreateOptionDto,
|
||||
ReplaceManyOptionsDto,
|
||||
} from './dtos';
|
||||
import {
|
||||
OptionKeyDuplicatedException,
|
||||
OptionNameDuplicatedException,
|
||||
} from './exceptions';
|
||||
import { OptionEntity } from './option.entity';
|
||||
|
||||
@Injectable()
|
||||
export class OptionService {
|
||||
constructor(
|
||||
@InjectRepository(OptionEntity)
|
||||
private readonly repository: Repository<OptionEntity>,
|
||||
) {}
|
||||
|
||||
private getInactiveOption(
|
||||
options: OptionEntity[],
|
||||
key: string,
|
||||
name: string,
|
||||
) {
|
||||
return options.find(
|
||||
(option) => option.key === 'deleted_' + key && option.name === name,
|
||||
);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async create({ fieldId, name, key }: CreateOptionDto) {
|
||||
const options = await this.repository.findBy({
|
||||
field: { id: fieldId },
|
||||
});
|
||||
|
||||
const inactiveOption = this.getInactiveOption(options, key, name);
|
||||
if (inactiveOption) {
|
||||
await this.repository.save(
|
||||
Object.assign(inactiveOption, { deletedAt: null, key }),
|
||||
);
|
||||
return inactiveOption;
|
||||
}
|
||||
|
||||
if (options.map((v) => v.name).includes(name)) {
|
||||
throw new OptionNameDuplicatedException();
|
||||
}
|
||||
if (options.map((v) => v.key).includes(key)) {
|
||||
throw new OptionKeyDuplicatedException();
|
||||
}
|
||||
const option = OptionEntity.from({ fieldId, name, key });
|
||||
|
||||
return await this.repository.save(option);
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async createMany({ fieldId, options }: CreateManyOptionsDto) {
|
||||
if (!validateUnique(options, 'name')) {
|
||||
throw new OptionNameDuplicatedException();
|
||||
}
|
||||
if (!validateUnique(options, 'key')) {
|
||||
throw new OptionKeyDuplicatedException();
|
||||
}
|
||||
|
||||
const newOptions = options.map((option) =>
|
||||
OptionEntity.from({ fieldId, ...option }),
|
||||
);
|
||||
|
||||
return await this.repository.save(newOptions);
|
||||
}
|
||||
|
||||
async findByFieldId({ fieldId }: { fieldId: number }) {
|
||||
return await this.repository.findBy({ field: { id: fieldId } });
|
||||
}
|
||||
|
||||
@Transactional()
|
||||
async replaceMany({ fieldId, options }: ReplaceManyOptionsDto) {
|
||||
if (!validateUnique(options, 'name')) {
|
||||
throw new OptionNameDuplicatedException();
|
||||
}
|
||||
if (!validateUnique(options, 'key')) {
|
||||
throw new OptionKeyDuplicatedException();
|
||||
}
|
||||
|
||||
const optionEntities = await this.repository.find({
|
||||
where: { field: { id: fieldId } },
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
const deletingOptionIds = optionEntities
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
.filter((option) => option.deletedAt === null)
|
||||
.filter((option) => (options ?? []).every((dto) => dto.id !== option.id))
|
||||
.map((v) => v.id);
|
||||
|
||||
if (deletingOptionIds.length > 0) {
|
||||
await this.repository.query(
|
||||
`
|
||||
UPDATE
|
||||
\`options\`
|
||||
SET
|
||||
\`key\` = CONCAT("deleted_", \`key\`),
|
||||
\`deleted_at\` = ?
|
||||
where
|
||||
\`id\` IN(?)
|
||||
`,
|
||||
[
|
||||
new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
deletingOptionIds,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
for (const option of options ?? []) {
|
||||
const inactiveOption = this.getInactiveOption(
|
||||
optionEntities,
|
||||
option.key,
|
||||
option.name,
|
||||
);
|
||||
if (inactiveOption) {
|
||||
await this.repository.save(
|
||||
Object.assign(inactiveOption, { deletedAt: null, key: option.key }),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const optionEntity = OptionEntity.from({ fieldId, ...option });
|
||||
if (option.id) optionEntity.id = option.id;
|
||||
|
||||
await this.repository.save(optionEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user