Initial deployment setup
Deploy staging / deploy (push) Failing after 6s

This commit is contained in:
root
2026-08-31 16:45:24 +09:00
commit 33453ecc55
3475 changed files with 850363 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
/**
* 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 { 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 { DashboardModule } from './domains/admin/dashboard/dashboard.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,
DashboardModule,
CategoryModule,
HealthModule,
MigrationModule,
ApiKeyModule,
IssueTrackerModule,
IssueModule,
ProjectModule,
RoleModule,
TenantModule,
UserModule,
MemberModule,
HistoryModule,
WebhookModule,
FeedbackStatisticsModule,
IssueStatisticsModule,
FeedbackIssueStatisticsModule,
APIModule,
SchedulerLockModule,
AIModule,
] as (typeof AuthModule)[];
@Module({
imports: [
HttpModule.register({ global: true, timeout: 5000, maxRedirects: 5 }),
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,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 { applyDecorators } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiForbiddenResponse,
ApiInternalServerErrorResponse,
ApiNotFoundResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { ApiErrorResponseDto } from '../dtos';
export const ApiStandardErrorResponses = () =>
applyDecorators(
ApiBadRequestResponse({ type: ApiErrorResponseDto }),
ApiUnauthorizedResponse({ type: ApiErrorResponseDto }),
ApiForbiddenResponse({ type: ApiErrorResponseDto }),
ApiNotFoundResponse({ type: ApiErrorResponseDto }),
ApiInternalServerErrorResponse({ type: ApiErrorResponseDto }),
);
@@ -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;
+18
View File
@@ -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 { default as DtoValidator } from './dto-validator';
export { ApiOkResponsePagination } from './api-ok-response-pagination.decorator';
export { ApiStandardErrorResponses } from './api-standard-error-responses.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,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, ApiPropertyOptional } from '@nestjs/swagger';
export class ApiErrorResponseDto {
@ApiProperty({ example: 'BAD_REQUEST' })
code: string;
@ApiProperty({
oneOf: [
{ type: 'string', example: 'Invalid channel id' },
{
type: 'array',
items: { type: 'string' },
example: ['title should not be empty'],
},
],
})
message: string | string[];
@ApiProperty({ example: 'Bad Request' })
error: string;
@ApiProperty({ example: 400 })
statusCode: number;
@ApiProperty({ example: '/api/admin/projects/1/channels/1/feedbacks' })
path: string;
@ApiPropertyOptional({ type: Object, example: { field: 'email' } })
details?: Record<string, unknown>;
}
@@ -0,0 +1,144 @@
/**
* 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 dynamicFieldValueSchema = {
oneOf: [
{ type: 'string' },
{ type: 'number' },
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
description:
'The value type is determined by the channel field format. Select and date fields may be null.',
};
export const DYNAMIC_FEEDBACK_REQUEST_SCHEMA = {
type: 'object',
description:
'Dynamic feedback fields. Use GET /projects/{projectId}/channels/{channelId}/fields to discover the accepted keys, formats, and select options.',
additionalProperties: dynamicFieldValueSchema,
properties: {
title: { type: 'string', example: 'Feedback title' },
contents: { type: 'string', example: 'Feedback contents' },
Category: { type: 'string', example: 'ERROR_QNA' },
IP: { type: 'string', example: '192.168.0.10' },
MAC_address: { type: 'string', example: '00:1A:2B:3C:4D:5E' },
issueNames: {
type: 'array',
items: { type: 'string' },
description:
'Optional issue names to connect. This control field is used for issue linking and is not saved as a feedback field.',
example: ['Login error'],
},
},
example: {
title: 'Feedback title',
contents: 'Feedback contents',
Category: 'ERROR_QNA',
IP: '192.168.0.10',
MAC_address: '00:1A:2B:3C:4D:5E',
issueNames: ['Login error'],
},
};
export const DYNAMIC_FEEDBACK_MULTIPART_SCHEMA = {
type: 'object',
description:
'Multipart feedback input. Non-file parts use the channel field keys; the images field accepts repeated binary files of any type.',
additionalProperties: { type: 'string' },
properties: {
title: { type: 'string', example: 'Feedback title' },
contents: { type: 'string', example: 'Feedback contents' },
issueNames: {
type: 'string',
description: 'JSON array or repeated value depending on the client.',
example: '["Login error"]',
},
images: {
type: 'array',
items: { type: 'string', format: 'binary' },
description: 'Repeated file attachments. Any file type is accepted within the upload limits.',
},
},
};
export const DYNAMIC_FEEDBACK_ITEM_SCHEMA = {
type: 'object',
description:
'Feedback item. Channel field values are returned as top-level dynamic properties.',
additionalProperties: dynamicFieldValueSchema,
properties: {
id: { type: 'number', example: 34 },
createdAt: {
type: 'string',
format: 'date-time',
example: '2026-08-25T09:00:00.000Z',
},
updatedAt: {
type: 'string',
format: 'date-time',
example: '2026-08-25T09:30:00.000Z',
},
issues: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number', example: 13 },
name: { type: 'string', example: 'Login error' },
status: { type: 'string', example: 'IN_PROGRESS' },
},
},
},
},
};
export const DYNAMIC_FEEDBACK_PAGINATION_SCHEMA = {
type: 'object',
properties: {
items: {
type: 'array',
items: DYNAMIC_FEEDBACK_ITEM_SCHEMA,
},
meta: {
type: 'object',
properties: {
itemCount: { type: 'number', example: 10 },
totalItems: { type: 'number', example: 35 },
itemsPerPage: { type: 'number', example: 10 },
currentPage: { type: 'number', example: 1 },
totalPages: { type: 'number', example: 4 },
},
},
},
};
export const DYNAMIC_FEEDBACK_QUERY_VALUE_SCHEMA = {
oneOf: [
{ type: 'string' },
{ type: 'number' },
{
type: 'array',
items: { oneOf: [{ type: 'string' }, { type: 'number' }] },
},
{
type: 'object',
properties: {
gte: { type: 'string', example: '2026-08-01' },
lt: { type: 'string', example: '2026-09-01' },
},
},
],
};
+21
View File
@@ -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 { PaginationDto } from './pagination.dto';
export { PaginationRequestDto } from './pagination-request.dto';
export { PaginationResponseDto } from './pagination-response.dto';
export { TimeRange } from './time-range.dto';
export { ApiErrorResponseDto } from './api-error-response.dto';
export * from './dynamic-feedback.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();
}
}
+16
View File
@@ -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,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum EventTypeEnum {
FEEDBACK_CREATION = 'FEEDBACK_CREATION',
ISSUE_CREATION = 'ISSUE_CREATION',
ISSUE_STATUS_CHANGE = 'ISSUE_STATUS_CHANGE',
ISSUE_ADDITION = 'ISSUE_ADDITION',
FEEDBACK_STATUS_CHANGE = 'FEEDBACK_STATUS_CHANGE',
FEEDBACK_COMMENT_CREATION = 'FEEDBACK_COMMENT_CREATION',
}
@@ -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 FeedbackPriorityEnum {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
CRITICAL = 'CRITICAL',
}
@@ -0,0 +1,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum FeedbackStatusEnum {
INIT = 'INIT',
ON_REVIEW = 'ON_REVIEW',
DETAILED_REVIEW = 'DETAILED_REVIEW',
IN_PROGRESS = 'IN_PROGRESS',
RESOLVED = 'RESOLVED',
PENDING = 'PENDING',
}
@@ -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',
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
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 { FeedbackStatusEnum } from './feedback-status.enum';
export { FeedbackPriorityEnum } from './feedback-priority.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,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum IssueStatusEnum {
INIT = 'INIT',
ON_REVIEW = 'ON_REVIEW',
DETAILED_REVIEW = 'DETAILED_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,329 @@
/**
* 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({
code: 'BAD_REQUEST',
message: 'Test error message',
error: 'BAD_REQUEST',
statusCode: HttpStatus.BAD_REQUEST,
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({
code: 'BAD_REQUEST',
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({
code: HttpStatus[statusCode],
message: 'Test error',
error: HttpStatus[statusCode],
statusCode,
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({
code: 'VALIDATION_ERROR',
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({
code: 'NO_CONTENT',
message: '',
error: 'NO_CONTENT',
statusCode: HttpStatus.NO_CONTENT,
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({
code: 'NO_CONTENT',
message: 'NO_CONTENT',
error: 'NO_CONTENT',
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({
code: 'NO_CONTENT',
message: 'NO_CONTENT',
error: 'NO_CONTENT',
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({
code: 'BAD_REQUEST',
message: 'Test error',
error: 'BAD_REQUEST',
statusCode: HttpStatus.BAD_REQUEST,
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({
code: 'INTERNAL_SERVER_ERROR',
message: 'Complex error',
error: 'Internal Server Error',
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
path: '/test-endpoint',
details: {
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({
code: 'BAD_REQUEST',
message: ['Error 1', 'Error 2', 'Error 3'],
error: 'BAD_REQUEST',
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({
code: 'OK',
message: 'OK',
error: 'OK',
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({
code: 'OK',
message: 'OK',
error: 'OK',
statusCode: HttpStatus.OK,
path: '/test-endpoint',
});
});
});
});
@@ -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 { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
import { Catch, HttpException, HttpStatus, Logger } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
type ExceptionResponse = {
message?: string | string[];
error?: string;
statusCode?: number;
details?: unknown;
[key: string]: unknown;
};
@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 });
const normalizedResponse: ExceptionResponse =
typeof exceptionResponse === 'string' ? { message: exceptionResponse }
: Array.isArray(exceptionResponse) ?
{ message: exceptionResponse.map((item) => String(item)) }
: ((exceptionResponse as ExceptionResponse | null) ?? {});
const {
message: responseMessage,
error: responseError,
statusCode: _responseStatusCode,
details: responseDetails,
...extraDetails
} = normalizedResponse;
const error = responseError ?? this.getHttpErrorName(statusCode);
const message = responseMessage ?? this.getHttpErrorName(statusCode);
const details =
responseDetails ??
(Object.keys(extraDetails).length > 0 ? extraDetails : undefined);
void response.status(statusCode).send({
code: this.toErrorCode(error, statusCode),
message,
error,
statusCode,
path: request.url,
...(details === undefined ?
{}
: {
details,
}),
});
}
private getHttpErrorName(statusCode: number): string {
return HttpStatus[statusCode] ?? 'HTTP_ERROR';
}
private toErrorCode(error: string, statusCode: number): string {
const code = error
.trim()
.replace(/([a-z])([A-Z])/g, '$1_$2')
.replace(/[^a-zA-Z0-9]+/g, '_')
.replace(/^_|_$/g, '')
.toUpperCase();
return code || this.getHttpErrorName(statusCode);
}
}
+16
View File
@@ -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';
+58
View File
@@ -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>;
}
+16
View File
@@ -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]}`;
},
},
});
};
};
+16
View File
@@ -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,
});
};
}
+119
View File
@@ -0,0 +1,119 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { 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'),
INITIAL_SUPER_ADMIN_PHONE_NUMBER: Joi.string().allow('').optional(),
ADMIN_CANDIDATE_EMAILS: Joi.string().allow('').optional(),
ALLOW_OAUTH_EMAIL_LINKING: Joi.boolean().default(false),
GITEA_API_URL: Joi.string().uri().default('https://gitea.hmac.kr/api/v1'),
GITEA_API_TOKEN: Joi.string().allow('').optional(),
GITHUB_API_TOKEN: Joi.string().allow('').optional(),
JIRA_API_EMAIL: Joi.string().email().allow('').optional(),
JIRA_API_TOKEN: Joi.string().allow('').optional(),
JIRA_WEBHOOK_SECRET: Joi.string().allow('').optional(),
JIRA_ISSUE_TYPE: Joi.string().default('Task'),
BASE_URL: Joi.string().optional(),
SUPPORT_API_BASE_URL: Joi.string().uri().default('http://127.0.0.1:8010'),
MASTER_API_KEY: Joi.string().allow('').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(),
},
),
ABC_WEBHOOK_ENABLED: Joi.boolean().default(false),
ABC_WEBHOOK_PATH: Joi.string().default('/integrations/abc/webhooks'),
ABC_WEBHOOK_TOKEN: Joi.string().allow('').optional(),
ABC_WEBHOOK_SIGNING_SECRET: Joi.string().allow('').optional(),
ABC_WEBHOOK_ALLOWED_PROJECT_IDS: Joi.string().allow('').optional(),
ABC_WEBHOOK_CLOCK_SKEW_SECONDS: Joi.number().default(300),
ABC_WEBHOOK_MAX_BODY_BYTES: Joi.number().default(262144),
NAVER_WORKS_ENABLED: Joi.boolean().default(false),
NAVER_WORKS_API_BASE_URL: Joi.string().uri().default('https://www.worksapis.com/v1.0'),
NAVER_WORKS_AUTH_URL: Joi.string().uri().default('https://auth.worksmobile.com/oauth2/v2.0/token'),
NAVER_WORKS_ACCESS_TOKEN: Joi.string().allow('').optional(),
NAVER_WORKS_BOT_ID: Joi.string().allow('').optional(),
NAVER_WORKS_DEFAULT_ROOM_ID: Joi.string().allow('').optional(),
NAVER_WORKS_CLIENT_ID: Joi.string().allow('').optional(),
NAVER_WORKS_CLIENT_SECRET: Joi.string().allow('').optional(),
NAVER_WORKS_SERVICE_ACCOUNT: Joi.string().allow('').optional(),
NAVER_WORKS_PRIVATE_KEY: Joi.string().allow('').optional(),
NAVER_WORKS_SCOPE: Joi.string().default('bot.message bot user.email.read'),
NAVER_WORKS_MAX_RETRIES: Joi.number().default(3),
NAVER_WORKS_MAX_MESSAGE_LENGTH: Joi.number().default(1000),
});
export const appConfig = registerAs('app', () => ({
port: process.env.APP_PORT,
address: process.env.APP_ADDRESS,
adminWebUrl: process.env.ADMIN_WEB_URL,
initialSuperAdminPhoneNumber: process.env.INITIAL_SUPER_ADMIN_PHONE_NUMBER,
adminCandidateEmails:
process.env.ADMIN_CANDIDATE_EMAILS?.split(',')
.map((email) => email.trim().toLowerCase())
.filter((email) => email.length > 0) ?? [],
allowOAuthEmailLinking: process.env.ALLOW_OAUTH_EMAIL_LINKING === 'true',
baseUrl: process.env.BASE_URL,
supportApiBaseUrl:
process.env.SUPPORT_API_BASE_URL ?? 'http://127.0.0.1:8010',
masterApiKey: process.env.MASTER_API_KEY,
giteaApiUrl: process.env.GITEA_API_URL,
giteaApiToken: process.env.GITEA_API_TOKEN,
githubApiToken: process.env.GITHUB_API_TOKEN,
jiraApiEmail: process.env.JIRA_API_EMAIL,
jiraApiToken: process.env.JIRA_API_TOKEN,
jiraWebhookSecret: process.env.JIRA_WEBHOOK_SECRET,
jiraIssueType: process.env.JIRA_ISSUE_TYPE,
enableAutoFeedbackDeletion:
process.env.AUTO_FEEDBACK_DELETION_ENABLED === 'true',
autoFeedbackDeletionPeriodDays:
process.env.AUTO_FEEDBACK_DELETION_PERIOD_DAYS,
abcWebhookEnabled: process.env.ABC_WEBHOOK_ENABLED === 'true',
abcWebhookPath: process.env.ABC_WEBHOOK_PATH,
abcWebhookToken: process.env.ABC_WEBHOOK_TOKEN,
abcWebhookSigningSecret: process.env.ABC_WEBHOOK_SIGNING_SECRET,
abcWebhookAllowedProjectIds: process.env.ABC_WEBHOOK_ALLOWED_PROJECT_IDS,
abcWebhookClockSkewSeconds: Number(process.env.ABC_WEBHOOK_CLOCK_SKEW_SECONDS ?? 300),
abcWebhookMaxBodyBytes: Number(process.env.ABC_WEBHOOK_MAX_BODY_BYTES ?? 262144),
naverWorksEnabled: process.env.NAVER_WORKS_ENABLED === 'true',
naverWorksApiBaseUrl:
process.env.NAVER_WORKS_API_BASE_URL ?? 'https://www.worksapis.com/v1.0',
naverWorksAuthUrl:
process.env.NAVER_WORKS_AUTH_URL ??
'https://auth.worksmobile.com/oauth2/v2.0/token',
naverWorksAccessToken: process.env.NAVER_WORKS_ACCESS_TOKEN,
naverWorksBotId: process.env.NAVER_WORKS_BOT_ID,
naverWorksDefaultRoomId: process.env.NAVER_WORKS_DEFAULT_ROOM_ID,
naverWorksClientId: process.env.NAVER_WORKS_CLIENT_ID,
naverWorksClientSecret: process.env.NAVER_WORKS_CLIENT_SECRET,
naverWorksServiceAccount: process.env.NAVER_WORKS_SERVICE_ACCOUNT,
naverWorksPrivateKey: process.env.NAVER_WORKS_PRIVATE_KEY,
naverWorksScope:
process.env.NAVER_WORKS_SCOPE ?? 'bot.message bot user.email.read',
naverWorksMaxRetries: Number(process.env.NAVER_WORKS_MAX_RETRIES ?? 3),
naverWorksMaxMessageLength: Number(process.env.NAVER_WORKS_MAX_MESSAGE_LENGTH ?? 1000),
serverId: uuidv4(),
}));
+29
View File
@@ -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,
}));
+18
View File
@@ -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,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 { 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 {
enabled,
host,
password,
port,
username,
sender,
cipherSpec,
opportunisticTLS,
tls,
} = configService.get('smtp', { infer: true }) ?? {};
return {
transport: enabled ? {
host,
port,
tls: { ciphers: cipherSpec },
auth:
username && password ?
{ user: username, pass: password }
: undefined,
secure: tls,
pool: true,
} : { jsonTransport: 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'
>
&#160;
</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'
>
&#160;
</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'
>
&#160;
</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'
>
&#160;
</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'
>
&#160;
</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'
>
&#160;
</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'
>
&#160;
</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'
>
&#160;
</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'
>
&#160;
</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\``);
}
}
@@ -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\`)`,
);
}
}
@@ -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\``);
}
}
@@ -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\``,
);
}
}
@@ -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\``);
}
}
@@ -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`,
);
}
}
@@ -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`,
);
}
}
@@ -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'`,
);
}
}
@@ -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`,
);
}
}
@@ -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\``);
}
}
@@ -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`,
);
}
}
@@ -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\``);
}
}
@@ -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`,
);
}
}
@@ -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\``);
}
}
@@ -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\``);
}
}
@@ -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\``);
}
}
@@ -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\``);
}
}
@@ -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`,
);
}
}
@@ -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`,
);
}
}
@@ -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;`);
}
}
@@ -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\``,
);
}
}
@@ -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\``);
}
}
@@ -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`,
);
}
}
@@ -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\``);
}
}
@@ -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`,
);
}
}
@@ -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 AddOauthSubjectOnUser1753167600000 implements MigrationInterface {
name = 'AddOauthSubjectOnUser1753167600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE `users` ADD `oauth_subject` varchar(255) NULL',
);
await queryRunner.query(
'ALTER TABLE `users` ADD UNIQUE INDEX `IDX_users_oauth_subject_sign_up_method` (`oauth_subject`, `sign_up_method`)',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE `users` DROP INDEX `IDX_users_oauth_subject_sign_up_method`',
);
await queryRunner.query('ALTER TABLE `users` DROP COLUMN `oauth_subject`');
}
}
@@ -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`,
);
}
}
@@ -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,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 AddOauthTenantId1760000000000 implements MigrationInterface {
name = 'AddOauthTenantId1760000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE users ADD oauth_tenant_id varchar(100) NULL',
);
await queryRunner.query(
'ALTER TABLE users DROP INDEX IDX_users_oauth_subject_sign_up_method',
);
await queryRunner.query(
'ALTER TABLE users ADD UNIQUE INDEX IDX_users_oauth_subject_tenant_sign_up_method (oauth_subject, oauth_tenant_id, sign_up_method)',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE users DROP INDEX IDX_users_oauth_subject_tenant_sign_up_method',
);
await queryRunner.query(
'ALTER TABLE users ADD UNIQUE INDEX IDX_users_oauth_subject_sign_up_method (oauth_subject, sign_up_method)',
);
await queryRunner.query(
'ALTER TABLE users DROP COLUMN oauth_tenant_id',
);
}
}
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
const projectNames = ['EGBIM', 'TOVA', 'GAIA', 'KNGIL', 'INTRANET_QNA'];
export class SeedRoleAccessProjects1760000001000 implements MigrationInterface {
name = 'SeedRoleAccessProjects1760000001000';
public async up(queryRunner: QueryRunner): Promise<void> {
for (const name of projectNames) {
await queryRunner.query(
'INSERT INTO projects ' +
'(created_at, updated_at, name, description, timezone, tenant_id) ' +
'SELECT CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6), ?, ?, ?, id ' +
'FROM tenant WHERE id = (SELECT MIN(id) FROM tenant) ' +
'AND NOT EXISTS (SELECT 1 FROM projects existing_project WHERE existing_project.name = ?)',
[
name,
name + ' Q&A',
JSON.stringify({ countryCode: 'KR', name: 'Asia/Seoul', offset: '+09:00' }),
name,
],
);
}
for (const name of projectNames) {
await queryRunner.query(
'INSERT INTO channels ' +
'(created_at, updated_at, name, description, image_config, feedback_search_max_days, project_id) ' +
'SELECT CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6), ?, ?, NULL, 365, project.id ' +
'FROM projects project WHERE project.name = ? ' +
'AND NOT EXISTS (SELECT 1 FROM channels existing_channel ' +
'WHERE existing_channel.project_id = project.id AND existing_channel.name = ?)',
[name, name + ' 기본 채널', name, name],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
for (const name of projectNames) {
await queryRunner.query(
'DELETE FROM channels WHERE name = ? AND project_id IN (SELECT id FROM projects WHERE name = ?)',
[name, name],
);
await queryRunner.query('DELETE FROM projects WHERE name = ?', [name]);
}
}
}
@@ -0,0 +1,98 @@
/**
* 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';
const projectRoles = [
{
name: 'PROJECT_MANAGER',
permissions: [
'feedback_download_read',
'feedback_update',
'feedback_delete',
'feedback_issue_update',
'issue_create',
'issue_update',
'issue_delete',
'project_update',
'project_delete',
'project_member_read',
'project_member_create',
'project_member_update',
'project_member_delete',
'project_role_read',
'project_role_create',
'project_role_update',
'project_role_delete',
'project_apikey_read',
'project_apikey_create',
'project_apikey_update',
'project_apikey_delete',
'project_tracker_read',
'project_tracker_update',
'project_webhook_read',
'project_webhook_create',
'project_webhook_update',
'project_webhook_delete',
'project_genai_read',
'project_genai_update',
'channel_create',
'channel_update',
'channel_delete',
'channel_field_read',
'channel_field_update',
'channel_image_read',
'channel_image_update',
],
},
{ name: 'END_USER', permissions: [] },
{ name: 'FEEDBACK_PROVIDER', permissions: [] },
];
export class SeedProjectRoles1760000002000 implements MigrationInterface {
name = 'SeedProjectRoles1760000002000';
public async up(queryRunner: QueryRunner): Promise<void> {
for (const role of projectRoles) {
await queryRunner.query(
'INSERT INTO roles (created_at, updated_at, name, permissions, project_id) ' +
'SELECT CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6), ?, ?, project.id ' +
'FROM projects project ' +
'WHERE NOT EXISTS (' +
'SELECT 1 FROM roles existing_role ' +
'WHERE existing_role.project_id = project.id AND existing_role.name = ?' +
')',
[role.name, role.permissions.join(','), role.name],
);
}
await queryRunner.query(
'UPDATE members member ' +
'JOIN roles legacy_role ON legacy_role.id = member.role_id ' +
'JOIN roles project_manager_role ON ' +
'project_manager_role.project_id = legacy_role.project_id ' +
'AND project_manager_role.name = ? ' +
'SET member.role_id = project_manager_role.id ' +
'WHERE legacy_role.name = ?',
['PROJECT_MANAGER', 'Admin'],
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
for (const role of projectRoles) {
await queryRunner.query('DELETE FROM roles WHERE name = ?', [role.name]);
}
}
}
@@ -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 type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDetailedReviewIssueStatus1760000003000
implements MigrationInterface
{
name = 'AddDetailedReviewIssueStatus1760000003000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE issues MODIFY COLUMN status enum ('INIT', 'ON_REVIEW', 'DETAILED_REVIEW', 'IN_PROGRESS', 'RESOLVED', 'PENDING') NOT NULL DEFAULT 'INIT'",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"UPDATE issues SET status = 'IN_PROGRESS' WHERE status = 'DETAILED_REVIEW'",
);
await queryRunner.query(
"ALTER TABLE issues MODIFY COLUMN status enum ('INIT', 'ON_REVIEW', 'IN_PROGRESS', 'RESOLVED', 'PENDING') NOT NULL DEFAULT 'INIT'",
);
}
}
@@ -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 AddSecondaryEmailsOnUser1760000004000
implements MigrationInterface
{
name = 'AddSecondaryEmailsOnUser1760000004000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE users ADD secondary_emails json NULL');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE users DROP COLUMN secondary_emails');
}
}
@@ -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 AddOauthRootTenantId1760000005000 implements MigrationInterface {
name = 'AddOauthRootTenantId1760000005000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE users ADD oauth_root_tenant_id varchar(100) NULL',
);
await queryRunner.query('ALTER TABLE users ADD oauth_tenant_ids json NULL');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE users DROP COLUMN oauth_tenant_ids');
await queryRunner.query(
'ALTER TABLE users DROP COLUMN oauth_root_tenant_id',
);
}
}
@@ -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 { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveOauthMembershipColumns1760000006000 implements MigrationInterface {
name = 'RemoveOauthMembershipColumns1760000006000';
public async up(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasColumn('users', 'oauth_root_tenant_id')) {
await queryRunner.dropColumn('users', 'oauth_root_tenant_id');
}
if (await queryRunner.hasColumn('users', 'oauth_tenant_ids')) {
await queryRunner.dropColumn('users', 'oauth_tenant_ids');
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasColumn('users', 'oauth_root_tenant_id'))) {
await queryRunner.query(
'ALTER TABLE `users` ADD `oauth_root_tenant_id` varchar(100) NULL',
);
}
if (!(await queryRunner.hasColumn('users', 'oauth_tenant_ids'))) {
await queryRunner.query(
'ALTER TABLE `users` ADD `oauth_tenant_ids` json NULL',
);
}
}
}
@@ -0,0 +1,51 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddExternalIssueSyncFields1760000007000
implements MigrationInterface
{
name = 'AddExternalIssueSyncFields1760000007000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
"ALTER TABLE issues ADD COLUMN external_issue_url varchar(1024) NULL AFTER external_issue_id",
);
await queryRunner.query(
"ALTER TABLE issues ADD COLUMN external_issue_status varchar(32) NULL AFTER external_issue_url",
);
await queryRunner.query(
"ALTER TABLE issues ADD COLUMN external_issue_sync_status varchar(32) NOT NULL DEFAULT 'NOT_SYNCED' AFTER external_issue_status",
);
await queryRunner.query(
"ALTER TABLE issues ADD COLUMN external_issue_sync_error text NULL AFTER external_issue_sync_status",
);
await queryRunner.query(
"ALTER TABLE issues ADD COLUMN external_issue_synced_at datetime(6) NULL AFTER external_issue_sync_error",
);
await queryRunner.query(
"UPDATE issues SET external_issue_sync_status = 'SYNCED' WHERE external_issue_id IS NOT NULL AND external_issue_id <> ''",
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE issues DROP COLUMN external_issue_synced_at');
await queryRunner.query('ALTER TABLE issues DROP COLUMN external_issue_sync_error');
await queryRunner.query('ALTER TABLE issues DROP COLUMN external_issue_sync_status');
await queryRunner.query('ALTER TABLE issues DROP COLUMN external_issue_status');
await queryRunner.query('ALTER TABLE issues DROP COLUMN external_issue_url');
}
}
@@ -0,0 +1,38 @@
/**
* 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 AddIssueAdminSettings1760000008000 implements MigrationInterface {
name = 'AddIssueAdminSettings1760000008000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE projects ADD COLUMN issue_admin_user_ids json NULL',
);
await queryRunner.query(
'ALTER TABLE issues ADD COLUMN issue_admin_user_id int NULL',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE issues DROP COLUMN issue_admin_user_id',
);
await queryRunner.query(
'ALTER TABLE projects DROP COLUMN issue_admin_user_ids',
);
}
}
@@ -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 { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFeedbackComments1760000009000 implements MigrationInterface {
name = 'AddFeedbackComments1760000009000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE feedback_comments (
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,
feedback_id int NOT NULL,
author_id varchar(255) NOT NULL,
author_tenant_id varchar(255) NOT NULL,
author_name varchar(255) NOT NULL,
content text NOT NULL,
is_internal tinyint NOT NULL DEFAULT 0,
comment_type varchar(20) NOT NULL DEFAULT 'COMMENT',
INDEX idx_feedback_comments_feedback_created (feedback_id, created_at),
PRIMARY KEY (id),
CONSTRAINT fk_feedback_comments_feedback
FOREIGN KEY (feedback_id) REFERENCES feedbacks(id) ON DELETE CASCADE
) ENGINE=InnoDB
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE feedback_comments');
}
}

Some files were not shown because too many files have changed in this diff Show More