first commit
This commit is contained in:
@@ -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 { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
|
||||
import { Request } from 'express';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import pino from 'pino';
|
||||
|
||||
import { appConfig, appConfigSchema } from './configs/app.config';
|
||||
import { jwtConfig, jwtConfigSchema } from './configs/jwt.config';
|
||||
import {
|
||||
MailerConfigModule,
|
||||
OpensearchConfigModule,
|
||||
TypeOrmConfigModule,
|
||||
} from './configs/modules';
|
||||
import { mysqlConfig, mysqlConfigSchema } from './configs/mysql.config';
|
||||
import {
|
||||
opensearchConfig,
|
||||
opensearchConfigSchema,
|
||||
} from './configs/opensearch.config';
|
||||
import { createOtelLogTransport } from './configs/otel-log.config';
|
||||
import { smtpConfig, smtpConfigSchema } from './configs/smtp.config';
|
||||
import { AuthModule } from './domains/admin/auth/auth.module';
|
||||
import { ChannelModule } from './domains/admin/channel/channel/channel.module';
|
||||
import { FieldModule } from './domains/admin/channel/field/field.module';
|
||||
import { OptionModule } from './domains/admin/channel/option/option.module';
|
||||
import { FeedbackModule } from './domains/admin/feedback/feedback.module';
|
||||
import { HistoryModule } from './domains/admin/history/history.module';
|
||||
import { AIModule } from './domains/admin/project/ai/ai.module';
|
||||
import { ApiKeyModule } from './domains/admin/project/api-key/api-key.module';
|
||||
import { CategoryModule } from './domains/admin/project/category/category.module';
|
||||
import { IssueTrackerModule } from './domains/admin/project/issue-tracker/issue-tracker.module';
|
||||
import { IssueModule } from './domains/admin/project/issue/issue.module';
|
||||
import { MemberModule } from './domains/admin/project/member/member.module';
|
||||
import { ProjectModule } from './domains/admin/project/project/project.module';
|
||||
import { RoleModule } from './domains/admin/project/role/role.module';
|
||||
import { WebhookModule } from './domains/admin/project/webhook/webhook.module';
|
||||
import { FeedbackIssueStatisticsModule } from './domains/admin/statistics/feedback-issue/feedback-issue-statistics.module';
|
||||
import { FeedbackStatisticsModule } from './domains/admin/statistics/feedback/feedback-statistics.module';
|
||||
import { IssueStatisticsModule } from './domains/admin/statistics/issue/issue-statistics.module';
|
||||
import { TenantModule } from './domains/admin/tenant/tenant.module';
|
||||
import { UserModule } from './domains/admin/user/user.module';
|
||||
import { APIModule } from './domains/api/api.module';
|
||||
import { HealthModule } from './domains/operation/health/health.module';
|
||||
import { MigrationModule } from './domains/operation/migration/migration.module';
|
||||
import { SchedulerLockModule } from './domains/operation/scheduler-lock/scheduler-lock.module';
|
||||
|
||||
export const domainModules = [
|
||||
AuthModule,
|
||||
ChannelModule,
|
||||
FieldModule,
|
||||
OptionModule,
|
||||
FeedbackModule,
|
||||
CategoryModule,
|
||||
HealthModule,
|
||||
MigrationModule,
|
||||
ApiKeyModule,
|
||||
IssueTrackerModule,
|
||||
IssueModule,
|
||||
ProjectModule,
|
||||
RoleModule,
|
||||
TenantModule,
|
||||
UserModule,
|
||||
MemberModule,
|
||||
HistoryModule,
|
||||
WebhookModule,
|
||||
FeedbackStatisticsModule,
|
||||
IssueStatisticsModule,
|
||||
FeedbackIssueStatisticsModule,
|
||||
APIModule,
|
||||
SchedulerLockModule,
|
||||
AIModule,
|
||||
] as (typeof AuthModule)[];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmConfigModule,
|
||||
OpensearchConfigModule,
|
||||
MailerConfigModule,
|
||||
PrometheusModule.register(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, opensearchConfig, smtpConfig, jwtConfig, mysqlConfig],
|
||||
validationSchema: appConfigSchema
|
||||
.concat(jwtConfigSchema)
|
||||
.concat(mysqlConfigSchema)
|
||||
.concat(smtpConfigSchema)
|
||||
.concat(opensearchConfigSchema),
|
||||
validationOptions: { abortEarly: true },
|
||||
}),
|
||||
LoggerModule.forRootAsync({
|
||||
useFactory: () => {
|
||||
const transport: pino.TransportMultiOptions = {
|
||||
targets: [
|
||||
{ target: 'pino-pretty', options: { singleLine: true } },
|
||||
createOtelLogTransport(),
|
||||
],
|
||||
};
|
||||
return {
|
||||
pinoHttp: {
|
||||
transport,
|
||||
autoLogging: {
|
||||
ignore: (req: Request) => req.originalUrl === '/api/health',
|
||||
},
|
||||
customLogLevel: (req, res, err) => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return 'silent';
|
||||
}
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
return 'silent';
|
||||
}
|
||||
if (res.statusCode >= 400 && res.statusCode < 500) {
|
||||
return 'warn';
|
||||
} else if (res.statusCode >= 500) {
|
||||
return 'error';
|
||||
} else if (err != null) {
|
||||
return 'error';
|
||||
}
|
||||
return 'info';
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
ClsModule.forRoot({
|
||||
global: true,
|
||||
middleware: { mount: true },
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
...domainModules,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -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 type { Type } from '@nestjs/common';
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
|
||||
|
||||
import { PaginationResponseDto } from '../dtos/pagination-response.dto';
|
||||
|
||||
export const ApiOkResponsePagination = <Dto extends Type<unknown>>(dto: Dto) =>
|
||||
applyDecorators(
|
||||
ApiExtraModels(PaginationResponseDto, dto),
|
||||
ApiOkResponse({
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(PaginationResponseDto) },
|
||||
{
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { $ref: getSchemaPath(dto) },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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 { IsString } from 'class-validator';
|
||||
|
||||
import DtoValidator from './dto-validator';
|
||||
|
||||
class Dto {
|
||||
@IsString()
|
||||
str: string;
|
||||
}
|
||||
|
||||
class TestClass {
|
||||
@DtoValidator()
|
||||
noParam() {
|
||||
return;
|
||||
}
|
||||
|
||||
@DtoValidator()
|
||||
dtoParam(_dto: Dto) {
|
||||
return;
|
||||
}
|
||||
|
||||
@DtoValidator()
|
||||
dtosParam(_dtos: Dto[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
@DtoValidator()
|
||||
compositionParam(_a: any, _dtos: Dto) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
describe('dto validator', () => {
|
||||
let instance: TestClass;
|
||||
beforeEach(() => {
|
||||
instance = new TestClass();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
instance.noParam();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
const dto = new Dto();
|
||||
dto.str = 'test';
|
||||
instance.dtoParam(dto);
|
||||
const dto2 = new Dto();
|
||||
void expect(instance.dtoParam(dto2)).rejects.toThrow();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
const dto = new Dto();
|
||||
dto.str = 'test';
|
||||
instance.dtosParam([dto]);
|
||||
const dto2 = new Dto();
|
||||
void expect(instance.dtosParam([dto2])).rejects.toThrow();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
const dto = new Dto();
|
||||
dto.str = '123';
|
||||
instance.compositionParam([1], dto);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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 { InternalServerErrorException } from '@nestjs/common';
|
||||
import type { ValidationError } from 'class-validator';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
type Method = (...args: object[]) => any;
|
||||
|
||||
const DtoValidator =
|
||||
() =>
|
||||
(
|
||||
target: unknown,
|
||||
propName: string,
|
||||
descriptor: TypedPropertyDescriptor<any>,
|
||||
) => {
|
||||
const methodRef = descriptor.value as Method;
|
||||
|
||||
descriptor.value = async function (...args: object[]): Promise<any> {
|
||||
for (const arg of args) {
|
||||
let errors: ValidationError[] = [];
|
||||
|
||||
if (!Array.isArray(arg) && typeof arg === 'object') {
|
||||
errors = await validate(arg);
|
||||
} else if (
|
||||
Array.isArray(arg) &&
|
||||
arg.length > 0 &&
|
||||
typeof arg[0] === 'object'
|
||||
) {
|
||||
errors = (
|
||||
await Promise.all(
|
||||
arg.map(async (item: object) => await validate(item)),
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new InternalServerErrorException(errors);
|
||||
}
|
||||
}
|
||||
return (await methodRef.call(this, ...args)) as object;
|
||||
};
|
||||
return descriptor;
|
||||
};
|
||||
export default DtoValidator;
|
||||
@@ -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 { default as DtoValidator } from './dto-validator';
|
||||
export { ApiOkResponsePagination } from './api-ok-response-pagination.decorator';
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 { ValidationError, Validator } from 'class-validator';
|
||||
|
||||
import { IsPassword } from './is-password';
|
||||
|
||||
class IsPasswordTest {
|
||||
@IsPassword()
|
||||
password: any;
|
||||
}
|
||||
|
||||
describe('IsPassword decorator', () => {
|
||||
it('', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.string.sample(
|
||||
faker.number.int({ min: 8, max: 15 }),
|
||||
);
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors) => {
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
it('minLength', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.string.sample(
|
||||
faker.number.int({ min: 0, max: 7 }),
|
||||
);
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||
expect(errors.length).toEqual(1);
|
||||
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('minLength');
|
||||
});
|
||||
});
|
||||
|
||||
it('isString', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.number.int({ min: 10000000, max: 99999999 });
|
||||
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||
expect(errors.length).toEqual(1);
|
||||
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
|
||||
});
|
||||
});
|
||||
|
||||
it('isString, minLength', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.number.int({ min: 0, max: 9999999 });
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||
expect(errors.length).toEqual(1);
|
||||
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
|
||||
expect(Object.keys(errors[0].constraints ?? {})[1]).toEqual('minLength');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { applyDecorators } from '@nestjs/common';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export function IsPassword() {
|
||||
return applyDecorators(IsString(), MinLength(8));
|
||||
}
|
||||
@@ -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 { PaginationDto } from './pagination.dto';
|
||||
export { PaginationRequestDto } from './pagination-request.dto';
|
||||
export { PaginationResponseDto } from './pagination-response.dto';
|
||||
export { TimeRange } from './time-range.dto';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 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 { Transform } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||
|
||||
import { toNumber } from '@/common/helper/cast.helper';
|
||||
|
||||
export class PaginationRequestDto {
|
||||
@Transform(({ value }: { value: string }) =>
|
||||
toNumber(value, { default: 10, min: 1 }),
|
||||
)
|
||||
@ApiProperty({ required: false, minimum: 1, default: 10, example: 10 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit: number;
|
||||
|
||||
@Transform(({ value }: { value: string }) =>
|
||||
toNumber(value, { default: 1, min: 1 }),
|
||||
)
|
||||
@ApiProperty({ required: false, minimum: 1, default: 1, example: 1 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page: number;
|
||||
|
||||
constructor(limit = 10, page = 1) {
|
||||
this.limit = limit;
|
||||
this.page = page;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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, Type } from 'class-transformer';
|
||||
import type { IPaginationMeta, Pagination } from 'nestjs-typeorm-paginate';
|
||||
|
||||
class PaginationMetaDto implements IPaginationMeta {
|
||||
@ApiProperty({ example: 10 })
|
||||
@Expose()
|
||||
itemCount: number;
|
||||
|
||||
@ApiProperty({ example: 100 })
|
||||
@Expose()
|
||||
totalItems?: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@Expose()
|
||||
itemsPerPage: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@Expose()
|
||||
totalPages?: number;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
@Expose()
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export abstract class PaginationResponseDto<T> implements Pagination<T> {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
@Type(() => PaginationMetaDto)
|
||||
meta: PaginationMetaDto;
|
||||
|
||||
abstract items: T[];
|
||||
}
|
||||
@@ -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 PaginationDto {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
export class TimeRange {
|
||||
@ApiProperty({ name: 'gte (UTC)' })
|
||||
gte: string;
|
||||
@ApiProperty({ name: 'lt (UTC)' })
|
||||
lt: string;
|
||||
}
|
||||
@@ -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 { DateTime } from 'luxon';
|
||||
import {
|
||||
BeforeInsert,
|
||||
BeforeUpdate,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export abstract class CommonEntity {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
beforeInsertHook() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!this.createdAt) {
|
||||
this.createdAt = DateTime.utc().toJSDate();
|
||||
}
|
||||
this.updatedAt = DateTime.utc().toJSDate();
|
||||
}
|
||||
|
||||
@BeforeUpdate()
|
||||
beforeUpdateHook() {
|
||||
this.updatedAt = DateTime.utc().toJSDate();
|
||||
}
|
||||
}
|
||||
@@ -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 { CommonEntity } from './common.entity';
|
||||
@@ -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 enum AIPromptStatusEnum {
|
||||
success = 'success',
|
||||
error = 'error',
|
||||
loading = 'loading',
|
||||
}
|
||||
@@ -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 enum AIProvidersEnum {
|
||||
OPEN_AI = 'OPEN_AI',
|
||||
GEMINI = 'GEMINI',
|
||||
}
|
||||
@@ -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 enum EventStatusEnum {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
}
|
||||
@@ -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 enum EventTypeEnum {
|
||||
FEEDBACK_CREATION = 'FEEDBACK_CREATION',
|
||||
ISSUE_CREATION = 'ISSUE_CREATION',
|
||||
ISSUE_STATUS_CHANGE = 'ISSUE_STATUS_CHANGE',
|
||||
ISSUE_ADDITION = 'ISSUE_ADDITION',
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
export enum FieldFormatEnum {
|
||||
text = 'text',
|
||||
keyword = 'keyword',
|
||||
number = 'number',
|
||||
select = 'select',
|
||||
multiSelect = 'multiSelect',
|
||||
date = 'date',
|
||||
images = 'images',
|
||||
aiField = 'aiField',
|
||||
}
|
||||
|
||||
export function isSelectFieldFormat(type: FieldFormatEnum) {
|
||||
return [FieldFormatEnum.select, FieldFormatEnum.multiSelect].includes(type);
|
||||
}
|
||||
@@ -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 enum FieldPropertyEnum {
|
||||
READ_ONLY = 'READ_ONLY',
|
||||
EDITABLE = 'EDITABLE',
|
||||
}
|
||||
@@ -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 enum FieldStatusEnum {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
export { FieldFormatEnum, isSelectFieldFormat } from './field-format.enum';
|
||||
export { FieldPropertyEnum } from './field-property.enum';
|
||||
export { FieldStatusEnum } from './field-status.enum';
|
||||
export { IssueStatusEnum } from './issue-status.enum';
|
||||
export { SortMethodEnum } from './sort-method.enum';
|
||||
export { EventTypeEnum } from './event-type.enum';
|
||||
export { EventStatusEnum } from './event-status.enum';
|
||||
export { WebhookStatusEnum } from './webhook-status.enum';
|
||||
export { QueryV2ConditionsEnum } from './query-v2-conditions.enum';
|
||||
@@ -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 enum IssueStatusEnum {
|
||||
INIT = 'INIT',
|
||||
ON_REVIEW = 'ON_REVIEW',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
RESOLVED = 'RESOLVED',
|
||||
PENDING = 'PENDING',
|
||||
}
|
||||
@@ -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 enum QueryV2ConditionsEnum {
|
||||
CONTAINS = 'CONTAINS',
|
||||
IS = 'IS',
|
||||
BETWEEN = 'BETWEEN',
|
||||
}
|
||||
@@ -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 enum SortMethodEnum {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
@@ -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 enum WebhookStatusEnum {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* 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 { ArgumentsHost } from '@nestjs/common';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { HttpExceptionFilter } from './http-exception.filter';
|
||||
|
||||
describe('HttpExceptionFilter', () => {
|
||||
let filter: HttpExceptionFilter;
|
||||
let mockRequest: FastifyRequest;
|
||||
let mockResponse: FastifyReply;
|
||||
let mockArgumentsHost: ArgumentsHost;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [HttpExceptionFilter],
|
||||
}).compile();
|
||||
|
||||
filter = module.get<HttpExceptionFilter>(HttpExceptionFilter);
|
||||
|
||||
// Mock FastifyRequest
|
||||
mockRequest = {
|
||||
url: '/test-endpoint',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
query: {},
|
||||
params: {},
|
||||
body: {},
|
||||
} as FastifyRequest;
|
||||
|
||||
// Mock FastifyReply
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as unknown as FastifyReply;
|
||||
|
||||
// Mock ArgumentsHost
|
||||
mockArgumentsHost = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue(mockRequest),
|
||||
getResponse: jest.fn().mockReturnValue(mockResponse),
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('catch', () => {
|
||||
it('should handle string exception response', () => {
|
||||
const exception = new HttpException(
|
||||
'Test error message',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
response: 'Test error message',
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object exception response', () => {
|
||||
const exceptionResponse = {
|
||||
message: 'Validation failed',
|
||||
error: 'Bad Request',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
};
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
message: 'Validation failed',
|
||||
error: 'Bad Request',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different HTTP status codes', () => {
|
||||
const statusCodes = [
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
HttpStatus.FORBIDDEN,
|
||||
HttpStatus.NOT_FOUND,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
];
|
||||
|
||||
statusCodes.forEach((statusCode) => {
|
||||
const exception = new HttpException('Test error', statusCode);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(statusCode);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
response: 'Test error',
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex object exception response', () => {
|
||||
const exceptionResponse = {
|
||||
message: ['Email is required', 'Password is too short'],
|
||||
error: 'Validation Error',
|
||||
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
details: {
|
||||
field: 'email',
|
||||
value: '',
|
||||
},
|
||||
};
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
message: ['Email is required', 'Password is too short'],
|
||||
error: 'Validation Error',
|
||||
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
path: '/test-endpoint',
|
||||
details: {
|
||||
field: 'email',
|
||||
value: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty string exception response', () => {
|
||||
const exception = new HttpException('', HttpStatus.NO_CONTENT);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
response: '',
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null exception response', () => {
|
||||
const exception = new HttpException(null as any, HttpStatus.NO_CONTENT);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
statusCode: HttpStatus.NO_CONTENT,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined exception response', () => {
|
||||
const exception = new HttpException(
|
||||
undefined as any,
|
||||
HttpStatus.NO_CONTENT,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
statusCode: HttpStatus.NO_CONTENT,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different request URLs', () => {
|
||||
const urls = [
|
||||
'/api/users',
|
||||
'/api/projects/123',
|
||||
'/api/auth/login',
|
||||
'/api/feedback?page=1&limit=10',
|
||||
];
|
||||
|
||||
urls.forEach((url) => {
|
||||
Object.assign(mockRequest, { url });
|
||||
const exception = new HttpException(
|
||||
'Test error',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
response: 'Test error',
|
||||
path: url,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested object exception response', () => {
|
||||
const exceptionResponse = {
|
||||
message: 'Complex error',
|
||||
error: 'Internal Server Error',
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
nested: {
|
||||
level1: {
|
||||
level2: {
|
||||
value: 'deep nested value',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
message: 'Complex error',
|
||||
error: 'Internal Server Error',
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
path: '/test-endpoint',
|
||||
nested: {
|
||||
level1: {
|
||||
level2: {
|
||||
value: 'deep nested value',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array exception response', () => {
|
||||
const exceptionResponse = ['Error 1', 'Error 2', 'Error 3'];
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
0: 'Error 1',
|
||||
1: 'Error 2',
|
||||
2: 'Error 3',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle boolean exception response', () => {
|
||||
const exception = new HttpException(true as any, HttpStatus.OK);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
statusCode: HttpStatus.OK,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle number exception response', () => {
|
||||
const exception = new HttpException(42 as any, HttpStatus.OK);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
statusCode: HttpStatus.OK,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
|
||||
import { Catch, HttpException, Logger } from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
|
||||
const statusCode = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
this.logger.error({ statusCode, exceptionResponse });
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
void response.status(statusCode).send({
|
||||
response: exceptionResponse,
|
||||
path: request.url,
|
||||
});
|
||||
} else {
|
||||
void response.status(statusCode).send({
|
||||
...exceptionResponse,
|
||||
statusCode,
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { HttpExceptionFilter } from './http-exception.filter';
|
||||
@@ -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.
|
||||
*/
|
||||
interface ToNumberOptions {
|
||||
default?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function toLowerCase(value: string): string {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
export function trim(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function toDate(value: string): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
export function toBoolean(value: string): boolean {
|
||||
value = value.toLowerCase();
|
||||
|
||||
return value === 'true' || value === '1' ? true : false;
|
||||
}
|
||||
|
||||
export function toNumber(value: string, opts: ToNumberOptions = {}): number {
|
||||
let newValue: number = Number.parseInt(value || String(opts.default), 10);
|
||||
|
||||
if (Number.isNaN(newValue)) {
|
||||
newValue = opts.default ?? 0;
|
||||
}
|
||||
|
||||
if (opts.min) {
|
||||
if (newValue < opts.min) {
|
||||
newValue = opts.min;
|
||||
}
|
||||
|
||||
if (opts.max && newValue > opts.max) {
|
||||
newValue = opts.max;
|
||||
}
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 { paginate } from 'nestjs-typeorm-paginate';
|
||||
import type {
|
||||
IPaginationMeta,
|
||||
IPaginationOptions,
|
||||
} from 'nestjs-typeorm-paginate';
|
||||
import type { FindManyOptions, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
export async function paginateHelper(
|
||||
queryBuilder: SelectQueryBuilder<any>,
|
||||
findOptions: FindManyOptions<any>,
|
||||
options: IPaginationOptions,
|
||||
) {
|
||||
const totalItems = await queryBuilder
|
||||
.clone()
|
||||
.setFindOptions(findOptions)
|
||||
.getCount();
|
||||
return await paginate(queryBuilder.setFindOptions(findOptions), {
|
||||
...options,
|
||||
countQueries: false,
|
||||
metaTransformer: (meta: IPaginationMeta): IPaginationMeta => {
|
||||
return {
|
||||
...meta,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / meta.itemsPerPage),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 CreateDataDto {
|
||||
id?: string;
|
||||
index: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
@@ -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 CreateIndexDto {
|
||||
index: 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 DeleteBulkDataDto {
|
||||
ids: number[];
|
||||
index: 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.
|
||||
*/
|
||||
import { PaginationDto } from '@/common/dtos';
|
||||
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
|
||||
|
||||
export class GetDataDto extends PaginationDto {
|
||||
index: string;
|
||||
query: OsQueryDto;
|
||||
sort: string[];
|
||||
}
|
||||
@@ -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 { CreateIndexDto } from './create-index.dto';
|
||||
export { PutMappingsDto } from './put-mappings.dto';
|
||||
export { CreateDataDto } from './create-data.dto';
|
||||
export { GetDataDto } from './get-data.dto';
|
||||
export { UpdateDataDto } from './update-data.dto';
|
||||
export { DeleteBulkDataDto } from './delete-bulk-data.dto';
|
||||
export { ScrollDto } from './scroll.dto';
|
||||
@@ -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 { Property } from '@opensearch-project/opensearch/api/_types/_common.mapping';
|
||||
|
||||
export class PutMappingsDto {
|
||||
index: string;
|
||||
mappings: Record<string, Property>;
|
||||
}
|
||||
@@ -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 type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
|
||||
|
||||
export class ScrollDto {
|
||||
index: string;
|
||||
query: OsQueryDto;
|
||||
sort: string[];
|
||||
size: number;
|
||||
scrollId: string | null;
|
||||
}
|
||||
@@ -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 UpdateDataDto {
|
||||
id: string;
|
||||
index: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
@@ -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 { OpensearchRepository } from './opensearch.repository';
|
||||
@@ -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 LargeWindowException extends BadRequestException {
|
||||
constructor(message: string) {
|
||||
super({
|
||||
code: ErrorCode.Opensearch.LargeWindow,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* 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 {
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { Client } from '@opensearch-project/opensearch';
|
||||
import type { TextProperty } from '@opensearch-project/opensearch/api/_types/_common.mapping';
|
||||
|
||||
import { getMockProvider } from '@/test-utils/util-functions';
|
||||
import { CreateDataDto, PutMappingsDto } from './dtos';
|
||||
import { OpensearchRepository } from './opensearch.repository';
|
||||
|
||||
const MockClient = {
|
||||
indices: {
|
||||
create: jest.fn(),
|
||||
putAlias: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
putMapping: jest.fn(),
|
||||
getMapping: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
index: jest.fn(),
|
||||
search: jest.fn(),
|
||||
scroll: jest.fn(),
|
||||
update: jest.fn(),
|
||||
deleteByQuery: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const OpensearchRepositoryProviders = [
|
||||
OpensearchRepository,
|
||||
getMockProvider('OPENSEARCH_CLIENT', MockClient),
|
||||
];
|
||||
|
||||
const COMPLICATE_JSON = {
|
||||
KEY1: 'VALUE1',
|
||||
KEY2: 'VALUE2',
|
||||
};
|
||||
|
||||
const MAPPING_JSON = {
|
||||
KEY1: {
|
||||
type: 'text',
|
||||
} as TextProperty,
|
||||
};
|
||||
|
||||
describe('Opensearch Repository Test suite', () => {
|
||||
let osRepo: OpensearchRepository;
|
||||
let osClient: Client;
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: OpensearchRepositoryProviders,
|
||||
}).compile();
|
||||
osRepo = module.get(OpensearchRepository);
|
||||
osClient = module.get('OPENSEARCH_CLIENT');
|
||||
});
|
||||
|
||||
describe('create index', () => {
|
||||
it('positive case', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const indexName = 'channel_' + index;
|
||||
jest.spyOn(osClient.indices, 'create');
|
||||
jest.spyOn(osClient.indices, 'putAlias');
|
||||
|
||||
await osRepo.createIndex({ index });
|
||||
|
||||
expect(osClient.indices.create).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.create).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
body: {
|
||||
settings: {
|
||||
index: { max_ngram_diff: 1 },
|
||||
analysis: {
|
||||
analyzer: {
|
||||
ngram_analyzer: {
|
||||
filter: ['lowercase', 'asciifolding', 'cjk_width'],
|
||||
tokenizer: 'ngram_tokenizer',
|
||||
type: 'custom',
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
ngram_tokenizer: {
|
||||
type: 'ngram',
|
||||
min_gram: 1,
|
||||
max_gram: 2,
|
||||
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(osClient.indices.putAlias).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putAlias).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
name: index,
|
||||
});
|
||||
});
|
||||
|
||||
it('creating index handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const error = new Error('Index creation failed');
|
||||
|
||||
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.createIndex({ index })).rejects.toThrow(
|
||||
'Index creation failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('creating index handles OpenSearch specific errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const error = {
|
||||
meta: {
|
||||
body: {
|
||||
error: {
|
||||
type: 'resource_already_exists_exception',
|
||||
reason: 'index already exists',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.createIndex({ index })).rejects.toEqual(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('putMappings', () => {
|
||||
it('putting mappings succeeds with an existent index', async () => {
|
||||
const dto = new PutMappingsDto();
|
||||
dto.index = faker.number.int().toString();
|
||||
dto.mappings = MAPPING_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
jest.spyOn(osClient.indices, 'putMapping');
|
||||
|
||||
await osRepo.putMappings(dto);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putMapping).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putMapping).toHaveBeenCalledWith({
|
||||
index: dto.index,
|
||||
body: { properties: dto.mappings },
|
||||
});
|
||||
});
|
||||
it('putting mappings fails with a nonexistent index', async () => {
|
||||
const dto = new PutMappingsDto();
|
||||
dto.index = faker.number.int().toString();
|
||||
dto.mappings = MAPPING_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 404 } as never);
|
||||
jest.spyOn(osClient.indices, 'putMapping');
|
||||
|
||||
await expect(osRepo.putMappings(dto)).rejects.toThrow(
|
||||
new NotFoundException('index is not found'),
|
||||
);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putMapping).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('putting mappings handles OpenSearch errors', async () => {
|
||||
const dto = new PutMappingsDto();
|
||||
dto.index = faker.number.int().toString();
|
||||
dto.mappings = MAPPING_JSON;
|
||||
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
|
||||
const error = {
|
||||
meta: {
|
||||
body: {
|
||||
error: {
|
||||
type: 'illegal_argument_exception',
|
||||
reason: 'mapping update failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(osClient.indices, 'putMapping')
|
||||
.mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.putMappings(dto)).rejects.toEqual(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createData', () => {
|
||||
it('creating data succeeds with valid inputs', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const dto = new CreateDataDto();
|
||||
dto.id = id;
|
||||
dto.index = index;
|
||||
dto.data = COMPLICATE_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||
body: {
|
||||
['channel_' + index]: {
|
||||
mappings: {
|
||||
properties: dto.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||
body: {
|
||||
_id: dto.id,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const response = await osRepo.createData(dto);
|
||||
|
||||
expect(response.id).toEqual(dto.id);
|
||||
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.index).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.index).toHaveBeenCalledWith({
|
||||
id: dto.id,
|
||||
index: 'channel_' + index,
|
||||
body: dto.data,
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
it('creating data fails with an invalid index', async () => {
|
||||
const invalidIndex = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const dto = new CreateDataDto();
|
||||
dto.id = id;
|
||||
dto.index = invalidIndex;
|
||||
dto.data = COMPLICATE_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ body: false } as never);
|
||||
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||
body: {
|
||||
['channel_' + faker.number.int().toString()]: {
|
||||
mappings: {
|
||||
properties: dto.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||
body: {
|
||||
_id: dto.id,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(osRepo.createData(dto)).rejects.toThrow(
|
||||
new NotFoundException('index is not found'),
|
||||
);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.getMapping).not.toHaveBeenCalled();
|
||||
expect(osClient.index).not.toHaveBeenCalled();
|
||||
});
|
||||
it('creating data fails with invalid data', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const data = COMPLICATE_JSON;
|
||||
const dto = new CreateDataDto();
|
||||
dto.id = id;
|
||||
dto.index = index;
|
||||
dto.data = {
|
||||
...data,
|
||||
invalidKey: 'invalidValue',
|
||||
};
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||
body: {
|
||||
['channel_' + index]: {
|
||||
mappings: {
|
||||
properties: data,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||
body: {
|
||||
_id: dto.id,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(osRepo.createData(dto)).rejects.toThrow(
|
||||
new InternalServerErrorException('error!!!'),
|
||||
);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.index).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getData', () => {
|
||||
it('getting data succeeds with valid inputs', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort = ['_id:desc'];
|
||||
const limit = 10;
|
||||
const page = 1;
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: [
|
||||
{ _source: { KEY1: 'VALUE1' } },
|
||||
{ _source: { KEY2: 'VALUE2' } },
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getData({ index, query, sort, limit, page });
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
from: 0,
|
||||
size: limit,
|
||||
sort,
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting data with empty sort adds default sort', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort: string[] = [];
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: [],
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.getData({ index, query, sort, page: 1, limit: 100 });
|
||||
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
from: 0,
|
||||
size: 100,
|
||||
sort: ['_id:desc'],
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting data handles large window exception', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
|
||||
const error = new Error('Result window is too large');
|
||||
error.name = 'OpenSearchClientError';
|
||||
|
||||
jest.spyOn(osClient, 'search').mockRejectedValue(error as never);
|
||||
|
||||
await expect(
|
||||
osRepo.getData({ index, query, sort: [], page: 1, limit: 100 }),
|
||||
).rejects.toThrow('Result window is too large');
|
||||
});
|
||||
|
||||
it('getting data handles total as object', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: [],
|
||||
total: { value: 100, relation: 'eq' },
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getData({
|
||||
index,
|
||||
query,
|
||||
sort: [],
|
||||
page: 1,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(result.total).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll', () => {
|
||||
it('scrolling with scrollId succeeds', async () => {
|
||||
const scrollId = faker.string.alphanumeric(32);
|
||||
const mockData = [{ KEY1: 'VALUE1' }, { KEY2: 'VALUE2' }];
|
||||
|
||||
jest.spyOn(osClient, 'scroll').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: mockData.map((data) => ({ _source: data })),
|
||||
},
|
||||
_scroll_id: scrollId,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.scroll({
|
||||
scrollId,
|
||||
index: '',
|
||||
size: 10,
|
||||
query: { bool: { must: [{ term: { status: 'active' } }] } },
|
||||
sort: [],
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(mockData);
|
||||
expect(result.scrollId).toEqual(scrollId);
|
||||
expect(osClient.scroll).toHaveBeenCalledWith({
|
||||
scroll_id: scrollId,
|
||||
scroll: '1m',
|
||||
});
|
||||
});
|
||||
|
||||
it('scrolling without scrollId performs initial search', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort = ['_id:desc'];
|
||||
const size = 10;
|
||||
const mockData = [{ KEY1: 'VALUE1' }];
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: mockData.map((data) => ({ _source: data })),
|
||||
},
|
||||
_scroll_id: 'new_scroll_id',
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.scroll({
|
||||
index,
|
||||
query,
|
||||
sort,
|
||||
size,
|
||||
scrollId: null,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(mockData);
|
||||
expect(result.scrollId).toEqual('new_scroll_id');
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
size,
|
||||
sort,
|
||||
body: { query },
|
||||
scroll: '1m',
|
||||
});
|
||||
});
|
||||
|
||||
it('scrolling with empty sort adds default sort', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort: string[] = [];
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: { hits: [] },
|
||||
_scroll_id: 'scroll_id',
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.scroll({ index, query, sort, size: 10, scrollId: null });
|
||||
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
size: 10,
|
||||
sort: ['_id:desc'],
|
||||
body: { query },
|
||||
scroll: '1m',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateData', () => {
|
||||
it('updating data succeeds with valid inputs', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const updateData = { KEY1: 'UPDATED_VALUE' };
|
||||
|
||||
jest.spyOn(osClient, 'update').mockResolvedValue({
|
||||
body: {
|
||||
_id: id,
|
||||
result: 'updated',
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.updateData({ index, id, data: updateData });
|
||||
|
||||
expect(osClient.update).toHaveBeenCalledWith({
|
||||
index,
|
||||
id,
|
||||
body: {
|
||||
doc: updateData,
|
||||
},
|
||||
refresh: true,
|
||||
retry_on_conflict: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('updating data handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const updateData = { KEY1: 'UPDATED_VALUE' };
|
||||
const error = new Error('Update failed');
|
||||
|
||||
jest.spyOn(osClient, 'update').mockRejectedValue(error as never);
|
||||
|
||||
await expect(
|
||||
osRepo.updateData({ index, id, data: updateData }),
|
||||
).rejects.toThrow('Update failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBulkData', () => {
|
||||
it('deleting bulk data succeeds with valid ids', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const ids = [faker.number.int(), faker.number.int()];
|
||||
|
||||
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
|
||||
body: {
|
||||
deleted: ids.length,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteBulkData({ index, ids });
|
||||
|
||||
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query: { terms: { _id: ids } } },
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting bulk data with empty ids array', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const ids: number[] = [];
|
||||
|
||||
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
|
||||
body: {
|
||||
deleted: 0,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteBulkData({ index, ids });
|
||||
|
||||
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query: { terms: { _id: ids } } },
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteIndex', () => {
|
||||
it('deleting index succeeds with valid index', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const indexName = 'channel_' + index;
|
||||
|
||||
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
|
||||
body: {
|
||||
acknowledged: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteIndex(index);
|
||||
|
||||
expect(osClient.indices.delete).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting index handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const error = new Error('Delete failed');
|
||||
|
||||
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.deleteIndex(index)).rejects.toThrow('Delete failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotal', () => {
|
||||
it('getting total count succeeds with valid query', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
|
||||
jest.spyOn(osClient, 'count').mockResolvedValue({
|
||||
body: {
|
||||
count: 100,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getTotal(index, query);
|
||||
|
||||
expect(result).toBe(100);
|
||||
expect(osClient.count).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting total count with complex query', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { status: 'active' } },
|
||||
{ range: { created_at: { gte: '2023-01-01' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(osClient, 'count').mockResolvedValue({
|
||||
body: {
|
||||
count: 50,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getTotal(index, query);
|
||||
|
||||
expect(result).toBe(50);
|
||||
expect(osClient.count).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting total count handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const error = new Error('Count failed');
|
||||
|
||||
jest.spyOn(osClient, 'count').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.getTotal(index, query)).rejects.toThrow(
|
||||
'Count failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllIndexes', () => {
|
||||
it('deleting all indexes succeeds', async () => {
|
||||
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
|
||||
body: {
|
||||
acknowledged: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteAllIndexes();
|
||||
|
||||
expect(osClient.indices.delete).toHaveBeenCalledWith({
|
||||
index: '_all',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting all indexes handles errors', async () => {
|
||||
const error = new Error('Delete all failed');
|
||||
|
||||
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.deleteAllIndexes()).rejects.toThrow(
|
||||
'Delete all failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 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 {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Client, errors } from '@opensearch-project/opensearch';
|
||||
import { Indices_PutMapping_Response } from '@opensearch-project/opensearch/api';
|
||||
|
||||
import type {
|
||||
CreateDataDto,
|
||||
CreateIndexDto,
|
||||
DeleteBulkDataDto,
|
||||
GetDataDto,
|
||||
PutMappingsDto,
|
||||
ScrollDto,
|
||||
UpdateDataDto,
|
||||
} from './dtos';
|
||||
import { LargeWindowException } from './large-window.exception';
|
||||
|
||||
@Injectable()
|
||||
export class OpensearchRepository {
|
||||
private logger = new Logger(OpensearchRepository.name);
|
||||
private opensearchClient: Client;
|
||||
constructor(@Inject('OPENSEARCH_CLIENT') opensearchClient: Client) {
|
||||
this.opensearchClient = opensearchClient;
|
||||
}
|
||||
|
||||
async createIndex({ index }: CreateIndexDto) {
|
||||
const indexName = 'channel_' + index;
|
||||
try {
|
||||
const response = await this.opensearchClient.indices.create({
|
||||
index: indexName,
|
||||
body: {
|
||||
settings: {
|
||||
index: { max_ngram_diff: 1 },
|
||||
analysis: {
|
||||
analyzer: {
|
||||
ngram_analyzer: {
|
||||
type: 'custom',
|
||||
filter: ['lowercase', 'asciifolding', 'cjk_width'],
|
||||
tokenizer: 'ngram_tokenizer',
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
ngram_tokenizer: {
|
||||
type: 'ngram',
|
||||
min_gram: 1,
|
||||
max_gram: 2,
|
||||
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (response) {
|
||||
this.logger.log(
|
||||
`Index created successfully: ${JSON.stringify(response.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.log(`Error creating index: ${error}`);
|
||||
if (error?.meta?.body) {
|
||||
this.logger.log(
|
||||
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.opensearchClient.indices.putAlias({
|
||||
index: indexName,
|
||||
name: index,
|
||||
});
|
||||
}
|
||||
|
||||
async putMappings({ index, mappings }: PutMappingsDto) {
|
||||
const { statusCode } = await this.opensearchClient.indices.exists({
|
||||
index,
|
||||
});
|
||||
if (statusCode !== 200) throw new NotFoundException('index is not found');
|
||||
|
||||
let response: Indices_PutMapping_Response;
|
||||
try {
|
||||
response = await this.opensearchClient.indices.putMapping({
|
||||
index,
|
||||
body: { properties: mappings },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.log(`Error put mapping: ${error}`);
|
||||
if (error?.meta?.body) {
|
||||
this.logger.log(
|
||||
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async createData({ id, index, data }: CreateDataDto) {
|
||||
const indexName = 'channel_' + index;
|
||||
const existence = await this.opensearchClient.indices.exists({
|
||||
index: indexName,
|
||||
});
|
||||
if (existence.statusCode !== 200)
|
||||
throw new NotFoundException('index is not found');
|
||||
|
||||
const response = await this.opensearchClient.indices.getMapping({
|
||||
index: indexName,
|
||||
});
|
||||
|
||||
const mappingKeys = Object.keys(
|
||||
response.body[indexName].mappings.properties as object,
|
||||
);
|
||||
const dataKeys = Object.keys(data);
|
||||
if (!dataKeys.every((v) => mappingKeys.includes(v))) {
|
||||
throw new InternalServerErrorException('error!!!');
|
||||
}
|
||||
|
||||
const { body } = await this.opensearchClient.index({
|
||||
id,
|
||||
index: indexName,
|
||||
body: data,
|
||||
refresh: true,
|
||||
});
|
||||
|
||||
return { id: body._id as unknown as number };
|
||||
}
|
||||
|
||||
async getData(dto: GetDataDto) {
|
||||
const { index, limit = 100, page = 1, query, sort } = dto;
|
||||
|
||||
if (sort.length === 0) {
|
||||
sort.push('_id:desc');
|
||||
}
|
||||
try {
|
||||
const { body } = await this.opensearchClient.search({
|
||||
index,
|
||||
from: (page - 1) * limit,
|
||||
size: limit,
|
||||
sort,
|
||||
body: { query },
|
||||
});
|
||||
|
||||
return {
|
||||
items: body.hits.hits.map((v) => ({
|
||||
...v._source,
|
||||
})) as Record<string, any>[],
|
||||
total:
|
||||
typeof body.hits.total === 'number' ?
|
||||
body.hits.total
|
||||
: (body.hits.total?.value ?? 0),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof errors.OpenSearchClientError) {
|
||||
if (error.message.includes('Result window is too large')) {
|
||||
throw new LargeWindowException(error.message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async scroll(dto: ScrollDto) {
|
||||
const { index, size, scrollId, query, sort } = dto;
|
||||
|
||||
if (sort.length === 0) sort.push('_id:desc');
|
||||
|
||||
if (scrollId) {
|
||||
const { body } = await this.opensearchClient.scroll({
|
||||
scroll_id: scrollId,
|
||||
scroll: '1m',
|
||||
});
|
||||
return this.convertToScrollData(body);
|
||||
}
|
||||
|
||||
const { body } = await this.opensearchClient.search({
|
||||
index,
|
||||
size,
|
||||
sort,
|
||||
body: { query },
|
||||
scroll: '1m',
|
||||
});
|
||||
return this.convertToScrollData(body);
|
||||
}
|
||||
|
||||
private convertToScrollData(body) {
|
||||
return {
|
||||
data: body.hits.hits.map((v) => ({
|
||||
...v._source,
|
||||
})) as Record<string, any>[],
|
||||
scrollId: body._scroll_id,
|
||||
};
|
||||
}
|
||||
|
||||
async updateData({ id, index, data }: UpdateDataDto) {
|
||||
try {
|
||||
await this.opensearchClient.update({
|
||||
id,
|
||||
index,
|
||||
body: { doc: data },
|
||||
refresh: true,
|
||||
retry_on_conflict: 5,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Error updating data: ${error}`);
|
||||
if (error?.meta?.body) {
|
||||
this.logger.error(
|
||||
`OpenSearch error details: ${JSON.stringify(error.meta.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBulkData({ ids, index }: DeleteBulkDataDto) {
|
||||
await this.opensearchClient.deleteByQuery({
|
||||
index,
|
||||
body: { query: { terms: { _id: ids } } },
|
||||
refresh: true,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteIndex(index: string) {
|
||||
await this.opensearchClient.indices.delete({ index: 'channel_' + index });
|
||||
}
|
||||
|
||||
async deleteAllIndexes() {
|
||||
await this.opensearchClient.indices.delete({ index: '_all' });
|
||||
}
|
||||
|
||||
async getTotal(index: string, query: object): Promise<number> {
|
||||
const { body } = await this.opensearchClient.count({
|
||||
index,
|
||||
body: { query },
|
||||
});
|
||||
|
||||
return body.count;
|
||||
}
|
||||
}
|
||||
@@ -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 type { ValidationArguments, ValidationOptions } from 'class-validator';
|
||||
import { registerDecorator } from 'class-validator';
|
||||
|
||||
export const ArrayDistinct = (
|
||||
property?: string,
|
||||
validationOptions?: ValidationOptions,
|
||||
) => {
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
name: 'ArrayDistinct',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
constraints: [property],
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(value: unknown): boolean {
|
||||
return Array.isArray(value) ?
|
||||
[...new Set(value)].length === value.length
|
||||
: false;
|
||||
},
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `must not contains duplicate entry for ${args.constraints[0]}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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 { ArrayDistinct } from './array-distinct';
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 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 { IsNotEmpty, validate } from 'class-validator';
|
||||
|
||||
import { TokenValidator } from './token-validator';
|
||||
|
||||
class TokenDto {
|
||||
@IsNotEmpty()
|
||||
@TokenValidator({ message: 'Invalid token format' })
|
||||
token: string;
|
||||
}
|
||||
|
||||
describe('TokenValidator', () => {
|
||||
it('should validate a correct token', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = 'validToken123456';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should invalidate a token with invalid characters', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = 'invalidToken$123';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
|
||||
});
|
||||
|
||||
it('should invalidate a token that is too short', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = 'short';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
|
||||
});
|
||||
|
||||
it('should invalidate an empty token', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = '';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 {
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ async: false })
|
||||
export class TokenValidatorConstraint implements ValidatorConstraintInterface {
|
||||
validate(token: string | null) {
|
||||
const regex = /^[a-zA-Z0-9._-]+$/;
|
||||
return (
|
||||
!token ||
|
||||
(typeof token === 'string' && regex.test(token) && token.length >= 16)
|
||||
);
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return 'Token must be at least 16 characters long and contain only alphanumeric characters, dots, hyphens, and underscores.';
|
||||
}
|
||||
}
|
||||
|
||||
export function TokenValidator(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: TokenValidatorConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 { registerAs } from '@nestjs/config';
|
||||
import Joi from 'joi';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const appConfigSchema = Joi.object({
|
||||
APP_PORT: Joi.number().default(4000),
|
||||
APP_ADDRESS: Joi.string().default('0.0.0.0'),
|
||||
ADMIN_WEB_URL: Joi.string().default('http://localhost:3000'),
|
||||
BASE_URL: Joi.string().optional(),
|
||||
AUTO_FEEDBACK_DELETION_ENABLED: Joi.boolean().default(false),
|
||||
AUTO_FEEDBACK_DELETION_PERIOD_DAYS: Joi.number().when(
|
||||
'AUTO_FEEDBACK_DELETION_ENABLED',
|
||||
{
|
||||
is: true,
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export const appConfig = registerAs('app', () => ({
|
||||
port: process.env.APP_PORT,
|
||||
address: process.env.APP_ADDRESS,
|
||||
adminWebUrl: process.env.ADMIN_WEB_URL,
|
||||
baseUrl: process.env.BASE_URL,
|
||||
enableAutoFeedbackDeletion:
|
||||
process.env.AUTO_FEEDBACK_DELETION_ENABLED === 'true',
|
||||
autoFeedbackDeletionPeriodDays:
|
||||
process.env.AUTO_FEEDBACK_DELETION_PERIOD_DAYS,
|
||||
serverId: uuidv4(),
|
||||
}));
|
||||
@@ -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 { registerAs } from '@nestjs/config';
|
||||
import Joi from 'joi';
|
||||
|
||||
export const jwtConfigSchema = Joi.object({
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
ACCESS_TOKEN_EXPIRED_TIME: Joi.string().default('10m'),
|
||||
REFRESH_TOKEN_EXPIRED_TIME: Joi.string().default('1h'),
|
||||
});
|
||||
|
||||
export const jwtConfig = registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET,
|
||||
accessTokenExpiredTime: process.env.ACCESS_TOKEN_EXPIRED_TIME,
|
||||
refreshTokenExpiredTime: process.env.REFRESH_TOKEN_EXPIRED_TIME,
|
||||
}));
|
||||
@@ -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 { OpensearchConfigModule } from './opensearch-config/opensearch-config.module';
|
||||
export { MailerConfigModule } from './mailer-config/mailer-config.module';
|
||||
export { TypeOrmConfigModule } from './typeorm-config/typeorm-config.module';
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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 { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MailerModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService<ConfigServiceType>) => {
|
||||
const {
|
||||
host,
|
||||
password,
|
||||
port,
|
||||
username,
|
||||
sender,
|
||||
cipherSpec,
|
||||
opportunisticTLS,
|
||||
tls,
|
||||
} = configService.get('smtp', { infer: true }) ?? {};
|
||||
return {
|
||||
transport: {
|
||||
host,
|
||||
port,
|
||||
tls: { ciphers: cipherSpec },
|
||||
auth:
|
||||
username && password ?
|
||||
{ user: username, pass: password }
|
||||
: undefined,
|
||||
secure: tls,
|
||||
pool: true,
|
||||
},
|
||||
defaults: { from: `"User feedback" <${sender}>` },
|
||||
template: {
|
||||
dir: __dirname + '/templates/',
|
||||
adapter: new HandlebarsAdapter(),
|
||||
options: { strict: true },
|
||||
},
|
||||
opportunisticTLS,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class MailerConfigModule {}
|
||||
@@ -0,0 +1,307 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- Compiled with Bootstrap Email version: 1.3.1 --><meta
|
||||
http-equiv='x-ua-compatible'
|
||||
content='ie=edge'
|
||||
/>
|
||||
<meta name='x-apple-disable-message-reformatting' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta
|
||||
name='format-detection'
|
||||
content='telephone=no, date=no, address=no, email=no'
|
||||
/>
|
||||
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
|
||||
<style type='text/css'>
|
||||
body,table,td{font-family:Helvetica,Arial,sans-serif
|
||||
!important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass
|
||||
p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass
|
||||
div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body
|
||||
a,#MessageViewBody
|
||||
a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-])
|
||||
td{border-spacing:0px;border-collapse:collapse}@media screen and
|
||||
(max-width: 600px){.gap-3.row,.gap-x-3.row{margin-right:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-x-3.row>table>tbody>tr>td{padding-right:12px
|
||||
!important}.gap-3.row,.gap-y-3.row{margin-bottom:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-y-3.row>table>tbody>tr>td{padding-bottom:12px
|
||||
!important}.gap-8.row,.gap-x-8.row{margin-right:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-x-8.row>table>tbody>tr>td{padding-right:32px
|
||||
!important}.gap-8.row,.gap-y-8.row{margin-bottom:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-y-8.row>table>tbody>tr>td{padding-bottom:32px
|
||||
!important}table.gap-3.stack-x>tbody>tr>td{padding-right:12px
|
||||
!important}table.gap-3.stack-y>tbody>tr>td{padding-bottom:12px
|
||||
!important}table.gap-8.stack-x>tbody>tr>td{padding-right:32px
|
||||
!important}table.gap-8.stack-y>tbody>tr>td{padding-bottom:32px
|
||||
!important}.w-full,.w-full>tbody>tr>td{width:100%
|
||||
!important}.w-56,.w-56>tbody>tr>td{width:224px
|
||||
!important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td
|
||||
a{padding:16px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0
|
||||
!important;line-height:0 !important;height:0
|
||||
!important}.s-3>tbody>tr>td{font-size:12px !important;line-height:12px
|
||||
!important;height:12px !important}.s-6>tbody>tr>td{font-size:24px
|
||||
!important;line-height:24px !important;height:24px
|
||||
!important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px
|
||||
!important;height:40px !important}}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
bgcolor='#ffffff'
|
||||
>
|
||||
<table
|
||||
valign='top'
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign='top' align='center'>
|
||||
<table
|
||||
align='center'
|
||||
style='width: 100%; max-width: 600px; margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr style='height: 32px;'>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
class='ax-center'
|
||||
align='center'
|
||||
style='margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; margin: 0;'
|
||||
align='left'
|
||||
>
|
||||
<img
|
||||
class=''
|
||||
width='88'
|
||||
height='100'
|
||||
src='{{baseUrl}}/assets/mailing/email-signup.png'
|
||||
alt='Email Signup'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='s-10 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='40'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-700 text-center text-lg'
|
||||
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
|
||||
align='center'
|
||||
>Sign up to UserFeedback</div>
|
||||
<table
|
||||
class='s-3 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='12'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-400 text-center text-sm'
|
||||
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
|
||||
align='center'
|
||||
>Please sign up using the button below.
|
||||
<br />
|
||||
This link will expire after 24 hours or if it is used
|
||||
once.</div>
|
||||
<table
|
||||
class='s-6 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='24'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='ax-center btn btn-black w-56'
|
||||
align='center'
|
||||
style='border-radius: 6px; border-collapse: separate !important; width: 224px; margin: 0 auto;'
|
||||
width='224'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; border-radius: 6px; width: 224px; margin: 0;'
|
||||
align='center'
|
||||
bgcolor='#000000'
|
||||
width='224'
|
||||
>
|
||||
<a
|
||||
href='{{link}}'
|
||||
style='color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #000000; padding: 8px 12px; border: 1px solid #000000;'
|
||||
>Sign Up</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div
|
||||
class='fw-400 text-secondary text-center text-xs'
|
||||
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
|
||||
align='center'
|
||||
>This is an automated message. Please do not reply to this
|
||||
email.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<table class='s-6' style='width: 100%;' width='100%'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/logo.svg'
|
||||
alt='Logo'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<img
|
||||
width='116'
|
||||
height='18'
|
||||
src='{{baseUrl}}/assets/mailing/title-ufb.png'
|
||||
alt='UserFeedback'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td align='left' valign='center'>
|
||||
<table align='center' style='margin: 0 auto;'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/globe-fill.png'
|
||||
alt='Website'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='https://github.com/line/abc-user-feedback'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/github-mark.png'
|
||||
alt='GitHub'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='mailto:dl_abc_userfeedback@linecorp.com'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/mail-fill.png'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,306 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- Compiled with Bootstrap Email version: 1.3.1 --><meta
|
||||
http-equiv='x-ua-compatible'
|
||||
content='ie=edge'
|
||||
/>
|
||||
<meta name='x-apple-disable-message-reformatting' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta
|
||||
name='format-detection'
|
||||
content='telephone=no, date=no, address=no, email=no'
|
||||
/>
|
||||
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
|
||||
<style type='text/css'>
|
||||
body,table,td{font-family:Helvetica,Arial,sans-serif
|
||||
!important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass
|
||||
p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass
|
||||
div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body
|
||||
a,#MessageViewBody
|
||||
a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-])
|
||||
td{border-spacing:0px;border-collapse:collapse}@media screen and
|
||||
(max-width: 600px){.gap-3.row,.gap-x-3.row{margin-right:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-x-3.row>table>tbody>tr>td{padding-right:12px
|
||||
!important}.gap-3.row,.gap-y-3.row{margin-bottom:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-y-3.row>table>tbody>tr>td{padding-bottom:12px
|
||||
!important}.gap-8.row,.gap-x-8.row{margin-right:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-x-8.row>table>tbody>tr>td{padding-right:32px
|
||||
!important}.gap-8.row,.gap-y-8.row{margin-bottom:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-y-8.row>table>tbody>tr>td{padding-bottom:32px
|
||||
!important}table.gap-3.stack-x>tbody>tr>td{padding-right:12px
|
||||
!important}table.gap-3.stack-y>tbody>tr>td{padding-bottom:12px
|
||||
!important}table.gap-8.stack-x>tbody>tr>td{padding-right:32px
|
||||
!important}table.gap-8.stack-y>tbody>tr>td{padding-bottom:32px
|
||||
!important}.w-full,.w-full>tbody>tr>td{width:100%
|
||||
!important}.w-56,.w-56>tbody>tr>td{width:224px
|
||||
!important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td
|
||||
a{padding:16px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0
|
||||
!important;line-height:0 !important;height:0
|
||||
!important}.s-3>tbody>tr>td{font-size:12px !important;line-height:12px
|
||||
!important;height:12px !important}.s-6>tbody>tr>td{font-size:24px
|
||||
!important;line-height:24px !important;height:24px
|
||||
!important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px
|
||||
!important;height:40px !important}}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
bgcolor='#ffffff'
|
||||
>
|
||||
<table
|
||||
valign='top'
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign='top' align='center'>
|
||||
<table
|
||||
align='center'
|
||||
style='width: 100%; max-width: 600px; margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr style='height: 32px;'>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
class='ax-center'
|
||||
align='center'
|
||||
style='margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; margin: 0;'
|
||||
align='left'
|
||||
>
|
||||
<img
|
||||
width='160'
|
||||
height='160'
|
||||
src='{{baseUrl}}/assets/mailing/email-reset.png'
|
||||
alt='Reset Password'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='s-10 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='40'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-700 text-center text-lg'
|
||||
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
|
||||
align='center'
|
||||
>Reset Password</div>
|
||||
<table
|
||||
class='s-3 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='12'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-400 text-center text-sm'
|
||||
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
|
||||
align='center'
|
||||
>Please change your password through the button below.
|
||||
<br />
|
||||
This link will expire after 24 hours or if it is used
|
||||
once.</div>
|
||||
<table
|
||||
class='s-6 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='24'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='ax-center btn btn-black w-56'
|
||||
align='center'
|
||||
style='border-radius: 6px; border-collapse: separate !important; width: 224px; margin: 0 auto;'
|
||||
width='224'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; border-radius: 6px; width: 224px; margin: 0;'
|
||||
align='center'
|
||||
bgcolor='#000000'
|
||||
width='224'
|
||||
>
|
||||
<a
|
||||
href='{{link}}'
|
||||
style='color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #000000; padding: 8px 12px; border: 1px solid #000000;'
|
||||
>Change Password</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div
|
||||
class='fw-400 text-secondary text-center text-xs'
|
||||
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
|
||||
align='center'
|
||||
>This is an automated message. Please do not reply to this
|
||||
email.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<table class='s-6' style='width: 100%;' width='100%'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/logo.svg'
|
||||
alt='Logo'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<img
|
||||
width='116'
|
||||
height='18'
|
||||
src='{{baseUrl}}/assets/mailing/title-ufb.png'
|
||||
alt='UserFeedback'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td align='left' valign='center'>
|
||||
<table align='center' style='margin: 0 auto;'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/globe-fill.png'
|
||||
alt='Website'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='https://github.com/line/abc-user-feedback'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/github-mark.png'
|
||||
alt='GitHub'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='mailto:dl_abc_userfeedback@linecorp.com'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/mail-fill.png'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- Compiled with Bootstrap Email version: 1.3.1 --><meta
|
||||
http-equiv='x-ua-compatible'
|
||||
content='ie=edge'
|
||||
/>
|
||||
<meta name='x-apple-disable-message-reformatting' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta
|
||||
name='format-detection'
|
||||
content='telephone=no, date=no, address=no, email=no'
|
||||
/>
|
||||
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
|
||||
<style type='text/css'>
|
||||
body,table,td{font-family:Helvetica,Arial,sans-serif
|
||||
!important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass
|
||||
p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass
|
||||
div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body
|
||||
a,#MessageViewBody
|
||||
a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-])
|
||||
td{border-spacing:0px;border-collapse:collapse}@media screen and
|
||||
(max-width: 600px){.gap-3.row,.gap-x-3.row{margin-right:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-x-3.row>table>tbody>tr>td{padding-right:12px
|
||||
!important}.gap-3.row,.gap-y-3.row{margin-bottom:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-y-3.row>table>tbody>tr>td{padding-bottom:12px
|
||||
!important}.gap-8.row,.gap-x-8.row{margin-right:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-x-8.row>table>tbody>tr>td{padding-right:32px
|
||||
!important}.gap-8.row,.gap-y-8.row{margin-bottom:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-y-8.row>table>tbody>tr>td{padding-bottom:32px
|
||||
!important}table.gap-3.stack-x>tbody>tr>td{padding-right:12px
|
||||
!important}table.gap-3.stack-y>tbody>tr>td{padding-bottom:12px
|
||||
!important}table.gap-8.stack-x>tbody>tr>td{padding-right:32px
|
||||
!important}table.gap-8.stack-y>tbody>tr>td{padding-bottom:32px
|
||||
!important}.w-full,.w-full>tbody>tr>td{width:100%
|
||||
!important}.w-56,.w-56>tbody>tr>td{width:224px
|
||||
!important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td
|
||||
a{padding:16px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0
|
||||
!important;line-height:0 !important;height:0
|
||||
!important}.s-3>tbody>tr>td{font-size:12px !important;line-height:12px
|
||||
!important;height:12px !important}.s-6>tbody>tr>td{font-size:24px
|
||||
!important;line-height:24px !important;height:24px
|
||||
!important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px
|
||||
!important;height:40px !important}}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
bgcolor='#ffffff'
|
||||
>
|
||||
<table
|
||||
valign='top'
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign='top' align='center'>
|
||||
<table
|
||||
align='center'
|
||||
style='width: 100%; max-width: 600px; margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr style='height: 32px;'>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
class='ax-center'
|
||||
align='center'
|
||||
style='margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; margin: 0;'
|
||||
align='left'
|
||||
>
|
||||
<img
|
||||
width='160'
|
||||
height='160'
|
||||
src='{{baseUrl}}/assets/mailing/email-reset.png'
|
||||
alt='Reset Password'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='s-10 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='40'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-700 text-center text-lg'
|
||||
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
|
||||
align='center'
|
||||
>Authentication Code</div>
|
||||
<table
|
||||
class='s-3 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='12'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-400 text-center text-sm'
|
||||
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
|
||||
align='center'
|
||||
>Please check the authentication code below.
|
||||
<br />
|
||||
This link will expire after 5 minutes or if it is used
|
||||
once.</div>
|
||||
<table
|
||||
class='s-6 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='24'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-700 text-center text-2xl'
|
||||
style='font-size: 24px; line-height: 28.8px; font-weight: 700 !important;'
|
||||
align='center'
|
||||
>{{code}}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div
|
||||
class='fw-400 text-secondary text-center text-xs'
|
||||
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
|
||||
align='center'
|
||||
>This is an automated message. Please do not reply to this
|
||||
email.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<table class='s-6' style='width: 100%;' width='100%'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/logo.svg'
|
||||
alt='Logo'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<img
|
||||
width='116'
|
||||
height='18'
|
||||
src='{{baseUrl}}/assets/mailing/title-ufb.png'
|
||||
alt='UserFeedback'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td align='left' valign='center'>
|
||||
<table align='center' style='margin: 0 auto;'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/globe-fill.png'
|
||||
alt='Website'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='https://github.com/line/abc-user-feedback'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/github-mark.png'
|
||||
alt='GitHub'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='mailto:dl_abc_userfeedback@linecorp.com'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/mail-fill.png'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Client, NodeOptions } from '@opensearch-project/opensearch';
|
||||
|
||||
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'OPENSEARCH_CLIENT',
|
||||
useFactory: (
|
||||
configService: ConfigService<ConfigServiceType>,
|
||||
): Client | undefined => {
|
||||
const {
|
||||
use,
|
||||
node,
|
||||
password,
|
||||
username,
|
||||
}: {
|
||||
use: boolean;
|
||||
node: string | string[] | NodeOptions | NodeOptions[];
|
||||
password: string;
|
||||
username: string;
|
||||
} = configService.get('opensearch', {
|
||||
infer: true,
|
||||
}) ?? { use: false, node: '', password: '', username: '' };
|
||||
return use ?
|
||||
new Client({ node, auth: { username, password } })
|
||||
: undefined;
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: ['OPENSEARCH_CLIENT'],
|
||||
})
|
||||
export class OpensearchConfigModule {}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Init1692159572819 implements MigrationInterface {
|
||||
name = 'Init1692159572819';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`tenant\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`site_name\` varchar(50) NOT NULL, \`description\` varchar(255) NULL, \`use_email\` tinyint NOT NULL DEFAULT 1, \`is_private\` tinyint NOT NULL DEFAULT 0, \`is_restrict_domain\` tinyint NOT NULL DEFAULT 0, \`allow_domains\` text NULL, \`use_o_auth\` tinyint NOT NULL DEFAULT 0, \`oauth_config\` json NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`issues\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`description\` varchar(255) NULL, \`status\` enum ('INIT', 'ON_REVIEW', 'IN_PROGRESS', 'RESOLVED', 'PENDING') NOT NULL DEFAULT 'INIT', \`external_issue_id\` varchar(255) NULL, \`feedback_count\` int NOT NULL DEFAULT '0', \`project_id\` int NULL, INDEX \`IDX_b7fd6df20da19c630741ea9045\` (\`status\`), INDEX \`IDX_db94fcc9ef9f968b43ec5d2b2a\` (\`feedback_count\`), INDEX \`IDX_8e64309f790aa4270b955a9947\` (\`project_id\`, \`created_at\`), UNIQUE INDEX \`IDX_b711d3eb6f21e35f5a0623dbe2\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`feedbacks\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`raw_data\` json NOT NULL, \`additional_data\` json NULL, \`channel_id\` int NULL, INDEX \`IDX_a640975f8ccf17d9337d4ff828\` (\`created_at\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`options\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`key\` varchar(255) NOT NULL, \`field_id\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`fields\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`key\` varchar(255) NOT NULL, \`description\` varchar(255) NULL, \`format\` enum ('text', 'keyword', 'number', 'boolean', 'select', 'multiSelect', 'date') NOT NULL, \`type\` enum ('DEFAULT', 'ADMIN', 'API') NOT NULL, \`status\` enum ('ACTIVE', 'INACTIVE') NOT NULL, \`channel_id\` int NULL, INDEX \`IDX_4b2181db660323e7ae856adeae\` (\`created_at\`), UNIQUE INDEX \`field-name-unique\` (\`name\`, \`channel_id\`), UNIQUE INDEX \`field-key-unique\` (\`key\`, \`channel_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`channels\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`description\` varchar(255) NULL, \`project_id\` int NULL, INDEX \`IDX_1233531abfb8d56d2a15050143\` (\`name\`, \`created_at\`), UNIQUE INDEX \`project-name-unique\` (\`name\`, \`project_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`projects\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`description\` varchar(255) NULL, \`tenant_id\` int NULL, UNIQUE INDEX \`IDX_2187088ab5ef2a918473cb9900\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`roles\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`permissions\` text NOT NULL, \`project_id\` int NULL, INDEX \`IDX_f4f2789197a3cbbc0182396b26\` (\`name\`, \`project_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`members\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`role_id\` int NULL, \`user_id\` int NULL, UNIQUE INDEX \`IDX_858f5ec01bcfe14ab3f2a328dc\` (\`role_id\`, \`user_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`users\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`email\` varchar(320) NULL, \`name\` varchar(255) NULL, \`department\` varchar(255) NULL, \`state\` enum ('Active', 'Blocked') NOT NULL DEFAULT 'Active', \`hash_password\` varchar(255) NULL, \`type\` enum ('SUPER', 'GENERAL') NOT NULL DEFAULT 'GENERAL', \`sign_up_method\` enum ('EMAIL', 'OAUTH') NOT NULL DEFAULT 'EMAIL', UNIQUE INDEX \`IDX_1301b11757c5b489adc8bc05e4\` (\`email\`, \`sign_up_method\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`histories\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`entity_name\` enum ('ApiKey', 'Channel', 'Feedback', 'Field', 'IssueTracker', 'Issue', 'Member', 'Option', 'Project', 'Role', 'Tenant', 'User', 'FeedbackIssue', 'Code') NOT NULL, \`entity_id\` decimal NOT NULL, \`action\` enum ('Create', 'Update', 'Delete', 'SoftDelete', 'Download', 'Recover') NOT NULL, \`entity\` json NOT NULL, \`user_id\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`codes\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`type\` enum ('EMAIL_VEIRIFICATION', 'RESET_PASSWORD', 'USER_INVITATION') NOT NULL, \`key\` varchar(255) NOT NULL, \`code\` varchar(255) NOT NULL, \`data\` varchar(255) NULL, \`is_verified\` tinyint NOT NULL DEFAULT 0, \`expired_at\` datetime NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`api_keys\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`value\` varchar(255) NOT NULL, \`project_id\` int NULL, UNIQUE INDEX \`IDX_2662a95fc4dd64493ca686a82f\` (\`value\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`issue_trackers\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`data\` json NULL, \`project_id\` int NULL, UNIQUE INDEX \`REL_0d000918b0c670b7d2488257dd\` (\`project_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`feedbacks_issues_issues\` (\`feedbacks_id\` int NOT NULL, \`issues_id\` int NOT NULL, INDEX \`IDX_3435079c319679ba3aaccd806b\` (\`feedbacks_id\`), INDEX \`IDX_6d6f24cf306a31c0af7b50973a\` (\`issues_id\`), PRIMARY KEY (\`feedbacks_id\`, \`issues_id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issues\` ADD CONSTRAINT \`FK_11f35e8296e10c229e7b68c68d4\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` ADD CONSTRAINT \`FK_4adbe5e6c46eba8a93a0265a078\` FOREIGN KEY (\`channel_id\`) REFERENCES \`channels\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`options\` ADD CONSTRAINT \`FK_dc520ce6f54769336c4afa5e9b9\` FOREIGN KEY (\`field_id\`) REFERENCES \`fields\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD CONSTRAINT \`FK_da856f4b147eb542917c5968c43\` FOREIGN KEY (\`channel_id\`) REFERENCES \`channels\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` ADD CONSTRAINT \`FK_63c4e21cafd9504a7c139144d1c\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` ADD CONSTRAINT \`FK_7393a03ef67e2ea91b81faa95dd\` FOREIGN KEY (\`tenant_id\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`roles\` ADD CONSTRAINT \`FK_cb48212dfe65dfe431d486034d2\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`members\` ADD CONSTRAINT \`FK_274c5ebb3c595f5a56f1f8fba9a\` FOREIGN KEY (\`role_id\`) REFERENCES \`roles\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`members\` ADD CONSTRAINT \`FK_da404b5fd9c390e25338996e2d1\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`api_keys\` ADD CONSTRAINT \`FK_f5de07dbb229225e2be643ff3d0\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issue_trackers\` ADD CONSTRAINT \`FK_0d000918b0c670b7d2488257dd7\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks_issues_issues\` ADD CONSTRAINT \`FK_3435079c319679ba3aaccd806b1\` FOREIGN KEY (\`feedbacks_id\`) REFERENCES \`feedbacks\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks_issues_issues\` ADD CONSTRAINT \`FK_6d6f24cf306a31c0af7b50973ab\` FOREIGN KEY (\`issues_id\`) REFERENCES \`issues\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks_issues_issues\` DROP FOREIGN KEY \`FK_6d6f24cf306a31c0af7b50973ab\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks_issues_issues\` DROP FOREIGN KEY \`FK_3435079c319679ba3aaccd806b1\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issue_trackers\` DROP FOREIGN KEY \`FK_0d000918b0c670b7d2488257dd7\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`api_keys\` DROP FOREIGN KEY \`FK_f5de07dbb229225e2be643ff3d0\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`members\` DROP FOREIGN KEY \`FK_da404b5fd9c390e25338996e2d1\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`members\` DROP FOREIGN KEY \`FK_274c5ebb3c595f5a56f1f8fba9a\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`roles\` DROP FOREIGN KEY \`FK_cb48212dfe65dfe431d486034d2\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` DROP FOREIGN KEY \`FK_7393a03ef67e2ea91b81faa95dd\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` DROP FOREIGN KEY \`FK_63c4e21cafd9504a7c139144d1c\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` DROP FOREIGN KEY \`FK_da856f4b147eb542917c5968c43\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`options\` DROP FOREIGN KEY \`FK_dc520ce6f54769336c4afa5e9b9\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` DROP FOREIGN KEY \`FK_4adbe5e6c46eba8a93a0265a078\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issues\` DROP FOREIGN KEY \`FK_11f35e8296e10c229e7b68c68d4\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_6d6f24cf306a31c0af7b50973a\` ON \`feedbacks_issues_issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_3435079c319679ba3aaccd806b\` ON \`feedbacks_issues_issues\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`feedbacks_issues_issues\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`REL_0d000918b0c670b7d2488257dd\` ON \`issue_trackers\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`issue_trackers\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_2662a95fc4dd64493ca686a82f\` ON \`api_keys\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`api_keys\``);
|
||||
await queryRunner.query(`DROP TABLE \`codes\``);
|
||||
await queryRunner.query(`DROP TABLE \`histories\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_1301b11757c5b489adc8bc05e4\` ON \`users\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`users\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_858f5ec01bcfe14ab3f2a328dc\` ON \`members\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`members\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_f4f2789197a3cbbc0182396b26\` ON \`roles\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`roles\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_2187088ab5ef2a918473cb9900\` ON \`projects\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`projects\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`project-name-unique\` ON \`channels\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_1233531abfb8d56d2a15050143\` ON \`channels\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`channels\``);
|
||||
await queryRunner.query(`DROP INDEX \`field-key-unique\` ON \`fields\``);
|
||||
await queryRunner.query(`DROP INDEX \`field-name-unique\` ON \`fields\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_4b2181db660323e7ae856adeae\` ON \`fields\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`fields\``);
|
||||
await queryRunner.query(`DROP TABLE \`options\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_a640975f8ccf17d9337d4ff828\` ON \`feedbacks\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`feedbacks\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_b711d3eb6f21e35f5a0623dbe2\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_8e64309f790aa4270b955a9947\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_db94fcc9ef9f968b43ec5d2b2a\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_b7fd6df20da19c630741ea9045\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`issues\``);
|
||||
await queryRunner.query(`DROP TABLE \`tenant\``);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class IssueNameUnique1692690482919 implements MigrationInterface {
|
||||
name = 'IssueNameUnique1692690482919';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_b711d3eb6f21e35f5a0623dbe2\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX \`issue-name-unique\` ON \`issues\` (\`name\`, \`project_id\`)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX \`issue-name-unique\` ON \`issues\``);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX \`IDX_b711d3eb6f21e35f5a0623dbe2\` ON \`issues\` (\`name\`)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class FeedbackStatistics1700795163534 implements MigrationInterface {
|
||||
name = 'FeedbackStatistics1700795163534';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`feedback_statistics\` (\`id\` int NOT NULL AUTO_INCREMENT, \`date\` date NOT NULL, \`count\` int NOT NULL DEFAULT '0', \`channel_id\` int NULL, UNIQUE INDEX \`channel-date-unique\` (\`channel_id\`, \`date\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedback_statistics\` ADD CONSTRAINT \`FK_7250a09c7ee486d1d24938a7054\` FOREIGN KEY (\`channel_id\`) REFERENCES \`channels\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedback_statistics\` DROP FOREIGN KEY \`FK_7250a09c7ee486d1d24938a7054\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`channel-date-unique\` ON \`feedback_statistics\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`feedback_statistics\``);
|
||||
}
|
||||
}
|
||||
+32
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ProjectTimezoneOffset1700795948817 implements MigrationInterface {
|
||||
name = 'ProjectTimezoneOffset1700795948817';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` ADD \`timezone_offset\` varchar(255) NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` DROP COLUMN \`timezone_offset\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
+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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class IssueStatistics1701090850194 implements MigrationInterface {
|
||||
name = 'IssueStatistics1701090850194';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`issue_statistics\` (\`id\` int NOT NULL AUTO_INCREMENT, \`date\` date NOT NULL, \`count\` int NOT NULL DEFAULT '0', \`project_id\` int NULL, UNIQUE INDEX \`project-date-unique\` (\`project_id\`, \`date\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issue_statistics\` ADD CONSTRAINT \`FK_86e6ee861d8895004659b4fe076\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issue_statistics\` DROP FOREIGN KEY \`FK_86e6ee861d8895004659b4fe076\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`project-date-unique\` ON \`issue_statistics\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`issue_statistics\``);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class FeedbackIssueStatistics1701234953280
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'FeedbackIssueStatistics1701234953280';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`feedback_issue_statistics\` (\`id\` int NOT NULL AUTO_INCREMENT, \`date\` date NOT NULL, \`feedback_count\` int NOT NULL DEFAULT '0', \`issue_id\` int NULL, UNIQUE INDEX \`issue-date-unique\` (\`issue_id\`, \`date\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedback_issue_statistics\` ADD CONSTRAINT \`FK_f90e8299de4ac2a05d3b6cbb2a6\` FOREIGN KEY (\`issue_id\`) REFERENCES \`issues\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedback_issue_statistics\` DROP FOREIGN KEY \`FK_f90e8299de4ac2a05d3b6cbb2a6\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`issue-date-unique\` ON \`feedback_issue_statistics\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`feedback_issue_statistics\``);
|
||||
}
|
||||
}
|
||||
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ImageConfig1701914129112 implements MigrationInterface {
|
||||
name = 'ImageConfig1701914129112';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` ADD \`image_config\` json NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` DROP COLUMN \`image_config\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ImageFormat1701931484534 implements MigrationInterface {
|
||||
name = 'ImageFormat1701931484534';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'boolean', 'select', 'multiSelect', 'date', 'image') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'boolean', 'select', 'multiSelect', 'date') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ProjectTimezoneOffsetDefault1702536442621
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'ProjectTimezoneOffsetDefault1702536442621';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` CHANGE \`timezone_offset\` \`timezone_offset\` varchar(255) NOT NULL DEFAULT '+00:00'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` CHANGE \`timezone_offset\` \`stimezone_offset\` varchar(255) NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ProjectTimezoneJson1705398750913 implements MigrationInterface {
|
||||
name = 'ProjectTimezoneJson1705398750913';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` CHANGE \`timezone_offset\` \`timezone\` varchar(255) NULL DEFAULT '+00:00'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` DROP COLUMN \`timezone\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` ADD \`timezone\` varchar(255) NOT NULL DEFAULT '{"countryCode":"KR","name":"Asia/Seoul","offset":"+09:00"}'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` DROP COLUMN \`timezone\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` ADD \`timezone\` varchar(255) NULL DEFAULT '+00:00'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` CHANGE \`timezone\` \`timezone_offset\` varchar(255) NULL DEFAULT '+00:00'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DeprecateBooleanField1707356935078 implements MigrationInterface {
|
||||
name = 'DeprecateBooleanField1707356935078';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'image') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'boolean', 'select', 'multiSelect', 'date', 'image') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CodeVerificationTryCount1707979877289
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CodeVerificationTryCount1707979877289';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`codes\` ADD \`try_count\` int NOT NULL DEFAULT '0'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`codes\` DROP COLUMN \`try_count\``);
|
||||
}
|
||||
}
|
||||
+32
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ChangeFieldFormatName1707979877290 implements MigrationInterface {
|
||||
name = 'ChangeFieldFormatName1707979877290';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'images') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'image') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class WebhookAndEvent1708666764079 implements MigrationInterface {
|
||||
name = 'WebhookAndEvent1708666764079';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`events\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`status\` enum ('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE', \`type\` enum ('FEEDBACK_CREATION', 'ISSUE_CREATION', 'ISSUE_STATUS_CHANGE', 'ISSUE_ADDITION') NOT NULL, \`webhook_id\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`webhooks\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`url\` varchar(255) NOT NULL, \`status\` enum ('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE', \`project_id\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`events_channels_channels\` (\`events_id\` int NOT NULL, \`channels_id\` int NOT NULL, INDEX \`IDX_97c39787187b453ae616ae1cb5\` (\`events_id\`), INDEX \`IDX_47c9e3e834366fe3217b6d2741\` (\`channels_id\`), PRIMARY KEY (\`events_id\`, \`channels_id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events\` ADD CONSTRAINT \`FK_81282a308a195ff5a7e6ba54fc3\` FOREIGN KEY (\`webhook_id\`) REFERENCES \`webhooks\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`webhooks\` ADD CONSTRAINT \`FK_8b545b4c86913152b9da6e04b08\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events_channels_channels\` ADD CONSTRAINT \`FK_97c39787187b453ae616ae1cb58\` FOREIGN KEY (\`events_id\`) REFERENCES \`events\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events_channels_channels\` ADD CONSTRAINT \`FK_47c9e3e834366fe3217b6d2741f\` FOREIGN KEY (\`channels_id\`) REFERENCES \`channels\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events_channels_channels\` DROP FOREIGN KEY \`FK_47c9e3e834366fe3217b6d2741f\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events_channels_channels\` DROP FOREIGN KEY \`FK_97c39787187b453ae616ae1cb58\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`webhooks\` DROP FOREIGN KEY \`FK_8b545b4c86913152b9da6e04b08\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events\` DROP FOREIGN KEY \`FK_81282a308a195ff5a7e6ba54fc3\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_47c9e3e834366fe3217b6d2741\` ON \`events_channels_channels\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_97c39787187b453ae616ae1cb5\` ON \`events_channels_channels\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`events_channels_channels\``);
|
||||
await queryRunner.query(`DROP TABLE \`webhooks\``);
|
||||
await queryRunner.query(`DROP TABLE \`events\``);
|
||||
}
|
||||
}
|
||||
+32
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWebhookInHistory1709172706829 implements MigrationInterface {
|
||||
name = 'AddWebhookInHistory1709172706829';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`histories\` CHANGE \`entity_name\` \`entity_name\` enum ('ApiKey', 'Channel', 'Feedback', 'Field', 'IssueTracker', 'Issue', 'Member', 'Option', 'Project', 'Role', 'Tenant', 'User', 'FeedbackIssue', 'Code', 'Webhook') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`histories\` CHANGE \`entity_name\` \`entity_name\` enum ('ApiKey', 'Channel', 'Feedback', 'Field', 'IssueTracker', 'Issue', 'Member', 'Option', 'Project', 'Role', 'Tenant', 'User', 'FeedbackIssue', 'Code') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SchedulerLock1709803978757 implements MigrationInterface {
|
||||
name = 'SchedulerLock1709803978757';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`scheduler_locks\` (\`lock_type\` enum ('FEEDBACK_STATISTICS', 'ISSUE_STATISTICS', 'FEEDBACK_ISSUE_STATISTICS', 'FEEDBACK_COUNT') NOT NULL, \`server_id\` varchar(255) NOT NULL, \`timestamp\` datetime NOT NULL, PRIMARY KEY (\`lock_type\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE \`scheduler_locks\``);
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DeprecateFieldFormat1713716840764 implements MigrationInterface {
|
||||
name = 'DeprecateFieldFormat1713716840764';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`feedbacks\` ADD \`data\` json`);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`feedbacks\` SET \`data\` = JSON_MERGE(COALESCE(\`raw_data\`, '{}'), COALESCE(\`additional_data\`, '{}'))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` CHANGE \`data\` \`data\` json NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` DROP COLUMN \`raw_data\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` DROP COLUMN \`additional_data\``,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD \`property\` enum ('READ_ONLY', 'EDITABLE')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`property\` = 'EDITABLE' WHERE \`type\` = 'ADMIN'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`property\` = 'EDITABLE' WHERE \`type\` = 'DEFAULT' AND \`key\` = 'issues'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`property\` = 'READ_ONLY' WHERE \`type\` = 'API'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`property\` = 'READ_ONLY' WHERE \`type\` = 'DEFAULT' AND \`key\` IN ('id', 'createdAt', 'updatedAt')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`property\` \`property\` enum ('READ_ONLY', 'EDITABLE') NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE \`fields\` DROP COLUMN \`type\``);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` ADD \`additional_data\` json NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` ADD \`raw_data\` json NULL`,
|
||||
);
|
||||
await queryRunner.query(`UPDATE \`feedbacks\` SET \`raw_data\` = \`data\``);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` CHANGE \`raw_data\` \`raw_data\` json NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE \`feedbacks\` DROP COLUMN \`data\``);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD \`type\` enum ('DEFAULT', 'ADMIN', 'API')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`type\` = 'ADMIN' WHERE \`property\` = 'EDITABLE'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`type\` = 'API' WHERE \`property\` = 'READ_ONLY'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`type\` = 'DEFAULT' WHERE \`key\` IN ('id', 'createdAt', 'updatedAt', 'issues')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`type\` \`type\` enum ('DEFAULT', 'ADMIN', 'API') NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE \`fields\` DROP COLUMN \`property\``);
|
||||
}
|
||||
}
|
||||
+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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTokenOnWebhook1720760282371 implements MigrationInterface {
|
||||
name = 'AddTokenOnWebhook1720760282371';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`webhooks\` ADD \`token\` varchar(255) NULL DEFAULT NULL AFTER \`url\``,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`webhooks\` DROP COLUMN \`token\``);
|
||||
}
|
||||
}
|
||||
+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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddOrderOnField1725935382221 implements MigrationInterface {
|
||||
name = 'AddOrderOnField1725935382221';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD \`order\` int NOT NULL DEFAULT '0'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`fields\` DROP COLUMN \`order\``);
|
||||
}
|
||||
}
|
||||
+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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ModifySchedulerLockEnum1728522901760
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'ModifySchedulerLockEnum1728522901760';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`scheduler_locks\` CHANGE \`lock_type\` \`lock_type\` enum ('FEEDBACK_STATISTICS', 'ISSUE_STATISTICS', 'FEEDBACK_ISSUE_STATISTICS', 'FEEDBACK_COUNT', 'FEEDBACK_DELETE') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`scheduler_locks\` CHANGE \`lock_type\` \`lock_type\` enum ('FEEDBACK_STATISTICS', 'ISSUE_STATISTICS', 'FEEDBACK_ISSUE_STATISTICS', 'FEEDBACK_COUNT') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+40
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DeleteUnusedTenantColumns1732588643312
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'DeleteUnusedTenantColumns1732588643312';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`tenant\` DROP COLUMN \`is_private\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`tenant\` DROP COLUMN \`is_restrict_domain\``,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`tenant\` ADD \`is_private\` tinyint NOT NULL DEFAULT 0`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`tenant\` ADD \`is_restrict_domain\` tinyint NOT NULL DEFAULT 0`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateCategoryTable1737437203196 implements MigrationInterface {
|
||||
name = 'CreateCategoryTable1737437203196';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
project_id INT NOT NULL,
|
||||
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
deleted_at DATETIME(6) DEFAULT NULL
|
||||
);`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX \`category-name-unique\` ON \`categories\` (\`project_id\`, \`name\`)`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`categories\`
|
||||
ADD CONSTRAINT \`fk_categories_project\`
|
||||
FOREIGN KEY (\`project_id\`)
|
||||
REFERENCES \`projects\`(\`id\`)
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issues\`
|
||||
ADD COLUMN \`category_id\` INT NULL,
|
||||
ADD CONSTRAINT \`fk_category\`
|
||||
FOREIGN KEY (\`category_id\`) REFERENCES \`categories\`(\`id\`)
|
||||
ON DELETE SET NULL;`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issues\`
|
||||
DROP FOREIGN KEY \`fk_category\`,
|
||||
DROP COLUMN \`category_id\`;`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`categories\` DROP FOREIGN KEY \`fk_categories_project\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`category-name-unique\` ON \`categories\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS categories;`);
|
||||
}
|
||||
}
|
||||
+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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddSearchMaxDaysOnChannel1746153314386
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddSearchMaxDaysOnChannel1746153314386';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` ADD \`feedback_search_max_days\` int NOT NULL DEFAULT '365'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` DROP COLUMN \`feedback_search_max_days\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddAIFieldTables1747019250371 implements MigrationInterface {
|
||||
name = 'AddAIFieldTables1747019250371';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`ai_integrations\` (
|
||||
\`id\` int NOT NULL AUTO_INCREMENT,
|
||||
\`project_id\` int NOT NULL UNIQUE,
|
||||
\`provider\` enum('OPEN_AI', 'GEMINI') NOT NULL,
|
||||
\`api_key\` varchar(255) NOT NULL,
|
||||
\`endpoint_url\` varchar(255) DEFAULT NULL,
|
||||
\`system_prompt\` text NOT NULL,
|
||||
\`token_threshold\` int NULL DEFAULT NULL,
|
||||
\`notification_threshold\` float NULL DEFAULT NULL,
|
||||
\`created_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
\`updated_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
\`deleted_at\` DATETIME(6) DEFAULT NULL,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_integrations\` ADD CONSTRAINT \`FK_project_id\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`ai_field_templates\` (
|
||||
\`id\` int NOT NULL AUTO_INCREMENT,
|
||||
\`project_id\` int NOT NULL,
|
||||
\`title\` varchar(255) NOT NULL DEFAULT '',
|
||||
\`prompt\` text NOT NULL,
|
||||
\`model\` varchar(255) NULL DEFAULT NULL,
|
||||
\`temperature\` float NOT NULL DEFAULT 0.7,
|
||||
\`created_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
\`updated_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
\`deleted_at\` DATETIME(6) DEFAULT NULL,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_field_templates\`
|
||||
ADD CONSTRAINT \`FK_ai_field_templates_project_id\`
|
||||
FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`)
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`ai_usages\` (
|
||||
\`id\` int NOT NULL AUTO_INCREMENT,
|
||||
\`created_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
\`updated_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
\`deleted_at\` DATETIME(6) DEFAULT NULL,
|
||||
\`year\` int NOT NULL,
|
||||
\`month\` int NOT NULL,
|
||||
\`day\` int NOT NULL,
|
||||
\`category\` enum('AI_FIELD', 'ISSUE_RECOMMEND') NOT NULL,
|
||||
\`provider\` enum('OPEN_AI', 'GEMINI') NOT NULL,
|
||||
\`used_tokens\` int NOT NULL,
|
||||
\`project_id\` int NOT NULL,
|
||||
PRIMARY KEY (\`id\`),
|
||||
FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE \`ai_usages\``);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_field_templates\` DROP FOREIGN KEY \`FK_ai_field_templates_project_id\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`ai_field_templates\``);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_integrations\` DROP FOREIGN KEY \`FK_project_id\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`ai_integrations\``);
|
||||
}
|
||||
}
|
||||
+57
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddAiFieldType1747717939594 implements MigrationInterface {
|
||||
name = 'AddAiFieldType1747717939594';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'images', 'aiField') NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD \`ai_field_template_id\` int NULL DEFAULT NULL AFTER \`order\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD CONSTRAINT \`FK_fields_ai_field_template_id\` FOREIGN KEY (\`ai_field_template_id\`) REFERENCES \`ai_field_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD \`ai_field_target_keys\` json NULL DEFAULT NULL AFTER \`ai_field_template_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD \`ai_field_auto_processing\` boolean NULL DEFAULT NULL AFTER \`ai_field_target_keys\``,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` DROP COLUMN \`ai_field_auto_processing\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` DROP COLUMN \`ai_field_target_keys\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` DROP FOREIGN KEY \`FK_fields_ai_field_template_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` DROP COLUMN \`ai_field_template_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'images') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+52
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddAiIssueTables1751434107382 implements MigrationInterface {
|
||||
name = 'AddAiIssueTables1751434107382';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`ai_issue_templates\` (
|
||||
\`id\` int NOT NULL AUTO_INCREMENT,
|
||||
\`channel_id\` int NOT NULL,
|
||||
\`target_field_keys\` json NOT NULL,
|
||||
\`prompt\` text NOT NULL,
|
||||
\`is_enabled\` tinyint NOT NULL DEFAULT 1,
|
||||
\`model\` varchar(255) NULL DEFAULT NULL,
|
||||
\`temperature\` float NOT NULL DEFAULT 0.7,
|
||||
\`data_reference_amount\` int NOT NULL DEFAULT 3,
|
||||
\`created_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
\`updated_at\` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
\`deleted_at\` DATETIME(6) DEFAULT NULL,
|
||||
PRIMARY KEY (\`id\`)
|
||||
) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_issue_templates\`
|
||||
ADD CONSTRAINT \`FK_ai_issue_templates_channel_id\`
|
||||
FOREIGN KEY (\`channel_id\`) REFERENCES \`channels\`(\`id\`)
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_issue_templates\` DROP FOREIGN KEY \`FK_ai_issue_templates_channel_id\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`ai_issue_templates\``);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ChangeAiFieldTemplateForeignKey1753082397344
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'ChangeAiFieldTemplateForeignKey1753082397344';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` DROP FOREIGN KEY \`FK_fields_ai_field_template_id\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD CONSTRAINT \`FK_fields_ai_field_template_id\` FOREIGN KEY (\`ai_field_template_id\`) REFERENCES \`ai_field_templates\`(\`id\`) ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` DROP FOREIGN KEY \`FK_fields_ai_field_template_id\``,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD CONSTRAINT \`FK_fields_ai_field_template_id\` FOREIGN KEY (\`ai_field_template_id\`) REFERENCES \`ai_field_templates\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 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 { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ChangeTemperatureDefaultValue1754965389371
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'ChangeTemperatureDefaultValue1754965389371';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_field_templates\`
|
||||
MODIFY COLUMN \`temperature\` float NOT NULL DEFAULT 0.5`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_issue_templates\`
|
||||
MODIFY COLUMN \`temperature\` float NOT NULL DEFAULT 0.5`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_field_templates\`
|
||||
MODIFY COLUMN \`temperature\` float NOT NULL DEFAULT 0.7`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_issue_templates\`
|
||||
MODIFY COLUMN \`temperature\` float NOT NULL DEFAULT 0.7`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+52
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ChangePromptToMediumText1755068715431
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'ChangePromptToMediumText1755068715431';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_field_templates\`
|
||||
MODIFY COLUMN \`prompt\` mediumtext NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_integrations\`
|
||||
MODIFY COLUMN \`system_prompt\` mediumtext NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_issue_templates\`
|
||||
MODIFY COLUMN \`prompt\` mediumtext NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_field_templates\`
|
||||
MODIFY COLUMN \`prompt\` text NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_integrations\`
|
||||
MODIFY COLUMN \`system_prompt\` text NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`ai_issue_templates\`
|
||||
MODIFY COLUMN \`prompt\` text NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 { ConfigService } from '@nestjs/config';
|
||||
import type { DataSourceOptions } from 'typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { mysqlConfig } from '@/configs/mysql.config';
|
||||
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||
import { TypeOrmConfigService } from './typeorm-config.service';
|
||||
|
||||
const env = mysqlConfig();
|
||||
console.log('env: ', env);
|
||||
const configService = new ConfigService({ mysql: env });
|
||||
const typeormConfigService = new TypeOrmConfigService(
|
||||
configService as unknown as ConfigService<ConfigServiceType, false>,
|
||||
);
|
||||
const typeormConfig =
|
||||
typeormConfigService.createTypeOrmOptions() as DataSourceOptions;
|
||||
|
||||
export default new DataSource(typeormConfig);
|
||||
@@ -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 { Logger, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { addTransactionalDataSource } from 'typeorm-transactional';
|
||||
|
||||
import { TypeOrmConfigService } from './typeorm-config.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
useClass: TypeOrmConfigService,
|
||||
dataSourceFactory: async (options: DataSourceOptions) => {
|
||||
Logger.log('start data source initalized');
|
||||
const datasource = await new DataSource(options).initialize();
|
||||
Logger.log('end data source initalized');
|
||||
|
||||
return addTransactionalDataSource(datasource);
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class TypeOrmConfigModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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 { join } from 'path';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type {
|
||||
TypeOrmModuleOptions,
|
||||
TypeOrmOptionsFactory,
|
||||
} from '@nestjs/typeorm';
|
||||
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
|
||||
|
||||
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||
|
||||
@Injectable()
|
||||
export class TypeOrmConfigService implements TypeOrmOptionsFactory {
|
||||
constructor(
|
||||
private readonly configService: ConfigService<ConfigServiceType>,
|
||||
) {}
|
||||
createTypeOrmOptions(): TypeOrmModuleOptions {
|
||||
const {
|
||||
main_url,
|
||||
sub_urls,
|
||||
auto_migration,
|
||||
}: { main_url: string; sub_urls: string[]; auto_migration: boolean } =
|
||||
this.configService.get('mysql', {
|
||||
infer: true,
|
||||
}) ?? { main_url: '', sub_urls: [], auto_migration: false };
|
||||
|
||||
return {
|
||||
type: 'mysql',
|
||||
replication: {
|
||||
master: { url: main_url },
|
||||
slaves:
|
||||
sub_urls.length ?
|
||||
sub_urls.map((url) => ({ url }))
|
||||
: [{ url: main_url }],
|
||||
},
|
||||
entities: [join(__dirname, '../../../**/*.entity.{ts,js}')],
|
||||
migrations: [join(__dirname, 'migrations/*.{ts,js}')],
|
||||
migrationsTableName: 'migrations',
|
||||
logging: ['warn', 'error'],
|
||||
migrationsRun: auto_migration,
|
||||
namingStrategy: new SnakeNamingStrategy(),
|
||||
timezone: '+00:00',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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 { registerAs } from '@nestjs/config';
|
||||
import dotenv from 'dotenv';
|
||||
import Joi from 'joi';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const mysqlConfigSchema = Joi.object({
|
||||
MYSQL_PRIMARY_URL: Joi.string().required(),
|
||||
MYSQL_SECONDARY_URLS: Joi.string().custom((value, helpers) => {
|
||||
const urls = JSON.parse(value);
|
||||
for (const url of urls) {
|
||||
if (!url.startsWith('mysql://')) {
|
||||
return helpers.error('any.invalid');
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}, 'custom validation'),
|
||||
AUTO_MIGRATION: Joi.boolean().default(true),
|
||||
});
|
||||
|
||||
export const mysqlConfig = registerAs('mysql', () => ({
|
||||
main_url: process.env.MYSQL_PRIMARY_URL,
|
||||
sub_urls:
|
||||
process.env.MYSQL_SECONDARY_URLS ?
|
||||
JSON.parse(process.env.MYSQL_SECONDARY_URLS)
|
||||
: [],
|
||||
auto_migration: process.env.AUTO_MIGRATION === 'true',
|
||||
}));
|
||||
@@ -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 { registerAs } from '@nestjs/config';
|
||||
import Joi from 'joi';
|
||||
|
||||
export const opensearchConfigSchema = Joi.object({
|
||||
OPENSEARCH_USE: Joi.boolean().default(false),
|
||||
OPENSEARCH_NODE: Joi.string().when('OPENSEARCH_USE', {
|
||||
is: true,
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
OPENSEARCH_USERNAME: Joi.string().allow('').optional().default(''),
|
||||
OPENSEARCH_PASSWORD: Joi.string().allow('').optional().default(''),
|
||||
});
|
||||
|
||||
export const opensearchConfig = registerAs('opensearch', () => ({
|
||||
use: process.env.OPENSEARCH_USE === 'true',
|
||||
node: process.env.OPENSEARCH_NODE,
|
||||
username: process.env.OPENSEARCH_USERNAME,
|
||||
password: process.env.OPENSEARCH_PASSWORD,
|
||||
}));
|
||||
@@ -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 type { TransportTargetOptions } from 'pino';
|
||||
|
||||
/**
|
||||
* Creates a pino transport configuration for OpenTelemetry log export.
|
||||
* The endpoint URL should be set via OTEL_EXPORTER_OTLP_LOGS_ENDPOINT environment variable.
|
||||
* @returns pino transport configuration
|
||||
*/
|
||||
export function createOtelLogTransport(): TransportTargetOptions {
|
||||
return {
|
||||
target: 'pino-opentelemetry-transport',
|
||||
options: {
|
||||
loggerName: 'abc-user-feedback-api',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 { registerAs } from '@nestjs/config';
|
||||
import Joi from 'joi';
|
||||
|
||||
export const smtpConfigSchema = Joi.object({
|
||||
SMTP_HOST: Joi.string().required(),
|
||||
SMTP_PORT: Joi.number().required(),
|
||||
SMTP_USERNAME: Joi.string().optional().allow(''),
|
||||
SMTP_PASSWORD: Joi.string().optional().allow(''),
|
||||
SMTP_SENDER: Joi.string().required(),
|
||||
SMTP_TLS: Joi.boolean().optional().default(false),
|
||||
SMTP_CIPHER_SPEC: Joi.string().when('SMTP_TLS', {
|
||||
is: true,
|
||||
then: Joi.optional().default('TLSv1.2'),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
SMTP_OPPORTUNISTIC_TLS: Joi.boolean().when('SMTP_TLS', {
|
||||
is: true,
|
||||
then: Joi.optional().default(true),
|
||||
otherwise: Joi.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const smtpConfig = registerAs('smtp', () => ({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || '25'),
|
||||
username: process.env.SMTP_USERNAME,
|
||||
password: process.env.SMTP_PASSWORD,
|
||||
sender: process.env.SMTP_SENDER,
|
||||
tls: process.env.SMTP_TLS === 'true',
|
||||
cipherSpec: process.env.SMTP_CIPHER_SPEC,
|
||||
opportunisticTLS: process.env.SMTP_OPPORTUNISTIC_TLS === 'true',
|
||||
}));
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
const Tenant = {
|
||||
TenantNotFound: 'TenantNotFound',
|
||||
TenantAlreadyExists: 'TenantAlreadyExists',
|
||||
};
|
||||
|
||||
const User = {
|
||||
UserAlreadyExists: 'UserAlreadyExists',
|
||||
UserNotFound: 'UserNotFound',
|
||||
PasswordNotMatched: 'PasswordNotMatched',
|
||||
EmailVerification: 'EmailVerification',
|
||||
EmailNotVerified: 'EmailNotVerified',
|
||||
PrivateServiceUserCreate: 'PrivateServiceUserCreate',
|
||||
NotAllowDomain: 'NotAllowDomain',
|
||||
InvalidCode: 'InvalidCode',
|
||||
InvalidPassword: 'InvalidPassword',
|
||||
};
|
||||
const Auth = {
|
||||
PasswordNotMatch: 'PasswordNotMatch',
|
||||
BlockedUser: 'BlockedUser',
|
||||
};
|
||||
|
||||
const Role = {
|
||||
RoleNotFound: 'RoleNotFound',
|
||||
OwnerIsImmutable: 'OwnerIsImmutable',
|
||||
RoleAlreadyExists: 'RoleAlreadyExists',
|
||||
};
|
||||
|
||||
const Mailing = {
|
||||
NotVerifiedEmail: 'NotVerifiedEmail',
|
||||
InvalidEmailCode: 'InvalidEmailCode',
|
||||
};
|
||||
|
||||
const Common = {
|
||||
InvalidDateFormat: 'InvalidDateFormat',
|
||||
};
|
||||
|
||||
const Project = {
|
||||
ProjectAlreadyExists: 'ProjectAlreadyExists',
|
||||
ProjectNotFound: 'ProjectNotFound',
|
||||
ProjectInvalidName: 'ProjectInvalidName',
|
||||
};
|
||||
|
||||
const Channel = {
|
||||
ChannelAlreadyExists: 'ChannelAlreadyExists',
|
||||
ChannelNotFound: 'ChannelNotFound',
|
||||
ChannelInvalidName: 'ChannelInvalidName',
|
||||
};
|
||||
|
||||
const Issue = {
|
||||
IssueNameDuplicated: 'IssueNameDuplicated',
|
||||
IssueInvalidName: 'IssueInvalidName',
|
||||
IssueNotFound: 'IssueNotFound',
|
||||
};
|
||||
|
||||
const Field = {
|
||||
FieldNameDuplicated: 'FieldNameDuplicated',
|
||||
FieldKeyDuplicated: 'FieldKeyDuplicated',
|
||||
};
|
||||
const Option = {
|
||||
OptionNameDuplicated: 'OptionNameDuplicated',
|
||||
OptionKeyDuplicated: 'OptionKeyDuplicated',
|
||||
};
|
||||
|
||||
const Feedback = {
|
||||
InvalidExpressionFormat: 'InvalidExpressionFormat',
|
||||
InvalidFieldType: 'InvalidFieldType',
|
||||
InvalidFieldRequest: 'InvalidFieldRequest',
|
||||
NotFoundAddressInfo: 'NotFoundAddressInfo',
|
||||
};
|
||||
|
||||
const Member = {
|
||||
MemberAlreadyExists: 'MemberAlreadyExists',
|
||||
MemberNotFound: 'MemberNotFound',
|
||||
MemberUpdateRoleNotMatchedProject: 'MemberUpdateRoleNotMatchedProject ',
|
||||
};
|
||||
|
||||
const Opensearch = {
|
||||
LargeWindow: 'LargeWindow',
|
||||
};
|
||||
|
||||
export const ErrorCode = {
|
||||
Tenant,
|
||||
Role,
|
||||
User,
|
||||
Auth,
|
||||
Mailing,
|
||||
Common,
|
||||
Feedback,
|
||||
Project,
|
||||
Channel,
|
||||
Issue,
|
||||
Field,
|
||||
Option,
|
||||
Member,
|
||||
Opensearch,
|
||||
};
|
||||
|
||||
export type ErrorCode = typeof ErrorCode;
|
||||
@@ -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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user