first commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Required environment variables
|
||||
JWT_SECRET=DEV
|
||||
|
||||
MYSQL_PRIMARY_URL=mysql://userfeedback:userfeedback@localhost:13306/userfeedback # required
|
||||
|
||||
ACCESS_TOKEN_EXPIRED_TIME=10m # default: 10m
|
||||
REFRESH_TOKEN_EXPIRED_TIME=1h # default: 1h
|
||||
|
||||
# ADMIN_WEB_URL=http://localhost:3000
|
||||
|
||||
# Optional environment variables
|
||||
|
||||
# BASE_URL=http://localhost:4000
|
||||
|
||||
# APP_PORT=4000 # default: 4000
|
||||
# APP_ADDRESS=0.0.0.0 # default: 0.0.0.0
|
||||
|
||||
# MYSQL_SECONDARY_URLS= ["mysql://userfeedback:userfeedback@localhost:13306/userfeedback"] # optional
|
||||
|
||||
SMTP_HOST=localhost # required
|
||||
SMTP_PORT=25 # required
|
||||
SMTP_SENDER=user@feedback.com # required
|
||||
# SMTP_USERNAME= # optional
|
||||
# SMTP_PASSWORD= # optional
|
||||
# SMTP_TLS= # default: false
|
||||
# SMTP_CIPHER_SPEC= # default: TLSv1.2 if SMTP_TLS=true
|
||||
# SMTP_OPPORTUNISTIC_TLS= # default: true if SMTP_TLS=true
|
||||
|
||||
# OPENSEARCH_USE=false # default: false
|
||||
# OPENSEARCH_NODE= # required if OPENSEARCH_USE=true
|
||||
# OPENSEARCH_USERNAME= # optional
|
||||
# OPENSEARCH_PASSWORD= # optional
|
||||
|
||||
# AUTO_MIGRATION=true # default: true
|
||||
|
||||
# MASTER_API_KEY= # default: none
|
||||
|
||||
# AUTO_FEEDBACK_DELETION_ENABLED=false # default: false
|
||||
# AUTO_FEEDBACK_DELETION_PERIOD_DAYS=365*5
|
||||
|
||||
# OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:4319/v1/logs
|
||||
# OTEL_RESOURCE_ATTRIBUTES=service.name=abc-user-feedback-api,service.version=1.0.0
|
||||
@@ -0,0 +1 @@
|
||||
*.hbs
|
||||
@@ -0,0 +1,136 @@
|
||||
# ABC User Feedback Backend
|
||||
|
||||
ABC User Feedback Backend provides API and its related operations. It is built with Node.js, NestJS, Typeorm, and many more.
|
||||
|
||||
## Setup
|
||||
|
||||
ABC User Feedback is using a mono-repo with multiple packages.
|
||||
|
||||
## Useful Targets
|
||||
|
||||
You can find a full list of targets in the [package.json](./package.json) file.
|
||||
|
||||
### `dev`
|
||||
|
||||
Runs the app in development mode.
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### `test`
|
||||
|
||||
Executes tests. This command applies to the environment variables in `.env.test` file.
|
||||
|
||||
```sh
|
||||
pnpm test
|
||||
```
|
||||
|
||||
### `test:e2e`
|
||||
|
||||
Executes e2e tests. This command applies to the environment variables in `.env.test` file.
|
||||
|
||||
```sh
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
### `lint`
|
||||
|
||||
Performs a linting check using ESLint.
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
### `build`
|
||||
|
||||
Builds the app for production. The distributable is expored to the `dist` folder in the repository's root folder.
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### `migration:generate`
|
||||
|
||||
Generate the migration file using typeorm. The file is generated in `src/configs/modules/typeorm-config/migrations`
|
||||
|
||||
```sh
|
||||
npm run migration:generate --name={NAME}
|
||||
```
|
||||
|
||||
### `migration:run`
|
||||
|
||||
Run the migration files for database migrations
|
||||
|
||||
```sh
|
||||
npm run migration:run
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The following is a list of environment variables used by the application, along with their descriptions and default values.
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
| Environment | Description | Default Value |
|
||||
| ------------------- | -------------------------------------------- | ------------- |
|
||||
| `JWT_SECRET` | Secret key for signing JSON Web Tokens (JWT) | _required_ |
|
||||
| `MYSQL_PRIMARY_URL` | Primary MySQL connection URL | _required_ |
|
||||
| `SMTP_HOST` | SMTP server host | _required_ |
|
||||
| `SMTP_PORT` | SMTP server port | _required_ |
|
||||
| `SMTP_SENDER` | Email address used as sender in emails | _required_ |
|
||||
|
||||
### Optional Environment Variables
|
||||
|
||||
<!-- markdownlint-disable MD060 -->
|
||||
|
||||
| Environment | Description | Default Value |
|
||||
| ------------------------------------ | -------------------------------------------------------------- | ---------------------------------------------- |
|
||||
| `ADMIN_WEB_URL` | Admin Web URL | `http://localhost:3000` |
|
||||
| `BASE_URL` | Public API server URL used in Swagger documentation | _optional_ |
|
||||
| `APP_PORT` | The port that the server runs on | `4000` |
|
||||
| `APP_ADDRESS` | The address that the server binds to | `0.0.0.0` |
|
||||
| `MYSQL_SECONDARY_URLS` | Secondary MySQL connection URLs (must be in JSON array format) | _optional_ |
|
||||
| `SMTP_USERNAME` | SMTP server authentication username | _optional_ |
|
||||
| `SMTP_PASSWORD` | SMTP server authentication password | _optional_ |
|
||||
| `SMTP_TLS` | Flag to enable SMTP server with secure option | `false` |
|
||||
| `SMTP_CIPHER_SPEC` | SMTP Cipher Algorithm Specification | `TLSv1.2` |
|
||||
| `SMTP_OPPORTUNISTIC_TLS` | Use Opportunistic TLS using STARTTLS | `true` |
|
||||
| `OPENSEARCH_USE` | Flag to enable OpenSearch integration | `false` |
|
||||
| `OPENSEARCH_NODE` | OpenSearch node URL | _required if `OPENSEARCH_USE=true`_ |
|
||||
| `OPENSEARCH_USERNAME` | OpenSearch username (if authentication is enabled) | "" |
|
||||
| `OPENSEARCH_PASSWORD` | OpenSearch password (if authentication is enabled) | "" |
|
||||
| `AUTO_MIGRATION` | Automatically perform database migration on application start | `true` |
|
||||
| `MASTER_API_KEY` | Master API key for privileged operations | _none_ |
|
||||
| `AUTO_FEEDBACK_DELETION_ENABLED` | Enable auto old feedback deletion cron on application start | `false` |
|
||||
| `AUTO_FEEDBACK_DELETION_PERIOD_DAYS` | Auto old feedback deletion period (in days) | _required if `AUTO_FEEDBACK_DELETION_ENABLED`_ |
|
||||
| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | OTLP HTTP logs endpoint that enables API log export when set | _optional_ |
|
||||
| `OTEL_RESOURCE_ATTRIBUTES` | OpenTelemetry resource attributes for exported logs | _optional_ |
|
||||
| `ACCESS_TOKEN_EXPIRED_TIME` | Duration until the access token expires | `10m` |
|
||||
| `REFRESH_TOKEN_EXPIRED_TIME` | Duration until the refresh token expires | `1h` |
|
||||
|
||||
<!-- markdownlint-enable MD060 -->
|
||||
|
||||
Please ensure that you set the required environment variables before starting the application. Optional variables can be set as needed based on your specific configuration and requirements.
|
||||
|
||||
If you want to export API logs through OpenTelemetry locally, set `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://localhost:4319/v1/logs`. You can also set `OTEL_RESOURCE_ATTRIBUTES=service.name=abc-user-feedback-api,service.version=1.1.1` to attach standard OpenTelemetry resource metadata to the exported logs. When this endpoint is configured, the API keeps writing pretty console logs and also sends the same logs to the OTLP HTTP endpoint. The `pino-opentelemetry-transport` package reads these standard OTEL environment variables directly, so no additional application configuration is required. For the full setup and verification flow, refer to the [developer guide configuration document](../docs/i18n/en/docusaurus-plugin-content-docs/current/02-developer-guide/01-installation/05-configuration.md).
|
||||
|
||||
## Swagger
|
||||
|
||||
The swagger documentation can be found on the `/docs` endpoint.
|
||||
|
||||
If you are serving the API server on a different URL (e.g., behind a reverse proxy), you can set the `BASE_URL` environment variable to specify the public URL. This will be used in the Swagger documentation to generate correct API endpoint URLs.
|
||||
|
||||
## Dashboard statistics data migration
|
||||
|
||||
Dashboard data is generated by mysql data every AM 00:00 with the timezone set by its project with schedulers.
|
||||
The schedulers generate data for 365 days.
|
||||
If you want to generate dashboard data by yourself, you can use `/migration/statistics` APIs (ref: [migration API](./src/domains/migration/migration.controller.ts))
|
||||
With the APIs you can generate data which are inserted more than 365 days.
|
||||
|
||||
If you are willing to change the project's timezone, you can manually change it in mysql database. (it is not available in admin web as it is not a usual case.)
|
||||
Then you should delete all statistics data and re-genearte by migration APIs.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn NestJS, check out the [NestJS documentation](https://nestjs.com/).
|
||||
@@ -0,0 +1,33 @@
|
||||
import tsParser from '@typescript-eslint/parser';
|
||||
import globals from 'globals';
|
||||
|
||||
import baseConfig from '@ufb/eslint-config/base';
|
||||
import nestjsConfig from '@ufb/eslint-config/nestjs';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', '**/*.js'],
|
||||
},
|
||||
...baseConfig,
|
||||
...nestjsConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.node, ...globals.jest },
|
||||
parser: tsParser,
|
||||
ecmaVersion: 5,
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.spec.ts', '**/*.test.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-assertion': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import mysql from 'mysql2/promise';
|
||||
|
||||
export async function createConnection() {
|
||||
return await mysql.createConnection({
|
||||
host: '127.0.0.1',
|
||||
port: 13307,
|
||||
user: 'root',
|
||||
password: 'userfeedback',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { join } from 'path';
|
||||
import { createConnection } from 'typeorm';
|
||||
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
|
||||
|
||||
import { createConnection as connect } from './database-utils';
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.MYSQL_PRIMARY_URL =
|
||||
'mysql://root:userfeedback@localhost:13307/integration';
|
||||
process.env.MYSQL_SECONDARY_URLS = JSON.stringify([
|
||||
'mysql://root:userfeedback@localhost:13307/integration',
|
||||
]);
|
||||
process.env.MASTER_API_KEY = 'master-api-key';
|
||||
process.env.AUTO_FEEDBACK_DELETION_ENABLED = 'true';
|
||||
process.env.AUTO_FEEDBACK_DELETION_PERIOD_DAYS = '30';
|
||||
|
||||
async function createTestDatabase() {
|
||||
const connection = await connect();
|
||||
|
||||
await connection.query(`DROP DATABASE IF EXISTS integration;`);
|
||||
await connection.query(`CREATE DATABASE IF NOT EXISTS integration;`);
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
async function runMigrations() {
|
||||
const connection = await createConnection({
|
||||
type: 'mysql',
|
||||
host: '127.0.0.1',
|
||||
port: 13307,
|
||||
username: 'root',
|
||||
password: 'userfeedback',
|
||||
database: 'integration',
|
||||
migrations: [
|
||||
join(
|
||||
__dirname,
|
||||
'../src/configs/modules/typeorm-config/migrations/*.{ts,js}',
|
||||
),
|
||||
],
|
||||
migrationsTableName: 'migrations',
|
||||
namingStrategy: new SnakeNamingStrategy(),
|
||||
timezone: '+00:00',
|
||||
});
|
||||
|
||||
await connection.runMigrations();
|
||||
await connection.close();
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
await createTestDatabase();
|
||||
await runMigrations();
|
||||
};
|
||||
@@ -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 { createConnection as connect } from './database-utils';
|
||||
|
||||
async function dropTestDatabase() {
|
||||
const connection = await connect();
|
||||
|
||||
await connection.query(`DROP DATABASE IF EXISTS integration;`);
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
await dropTestDatabase();
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"displayName": "api-integration",
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@/(.*)$": ["<rootDir>/../src/$1"]
|
||||
},
|
||||
"testRegex": ".integration-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": ["node_modules/(?!@faker-js|uuid)"],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/../integration-test/jest-integration.setup.ts"
|
||||
],
|
||||
"globalSetup": "<rootDir>/../integration-test/global.setup.ts",
|
||||
"globalTeardown": "<rootDir>/../integration-test/global.teardown.ts"
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
jest.mock('@nestjs-modules/mailer/dist/adapters/handlebars.adapter', () => {
|
||||
return {
|
||||
HandlebarsAdapter: jest.fn(),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import { ApiKeyService } from '@/domains/admin/project/api-key/api-key.service';
|
||||
import { CreateApiKeyRequestDto } from '@/domains/admin/project/api-key/dtos/requests';
|
||||
import type { FindApiKeysResponseDto } from '@/domains/admin/project/api-key/dtos/responses';
|
||||
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||
|
||||
describe('ApiKeyController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
let authService: AuthService;
|
||||
let tenantService: TenantService;
|
||||
let projectService: ProjectService;
|
||||
let _apiKeyService: ApiKeyService;
|
||||
let configService: ConfigService;
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
let project: ProjectEntity;
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
projectService = module.get(ProjectService);
|
||||
_apiKeyService = module.get(ApiKeyService);
|
||||
configService = module.get(ConfigService);
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
await tenantService.create(dto);
|
||||
|
||||
project = await projectService.create({
|
||||
name: faker.lorem.words(),
|
||||
description: faker.lorem.lines(1),
|
||||
timezone: {
|
||||
countryCode: 'KR',
|
||||
name: 'Asia/Seoul',
|
||||
offset: '+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/api-keys (POST)', () => {
|
||||
it('should create an API key', async () => {
|
||||
const dto = new CreateApiKeyRequestDto();
|
||||
dto.value = 'TestApiKey1234567890';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/api-keys`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201)
|
||||
.then(
|
||||
({
|
||||
body,
|
||||
}: {
|
||||
body: {
|
||||
id: number;
|
||||
value: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
}) => {
|
||||
expect(body).toHaveProperty('id');
|
||||
expect(body).toHaveProperty('value');
|
||||
expect(body).toHaveProperty('createdAt');
|
||||
expect(body.value).toBe('TestApiKey1234567890');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an API key with auto-generated value when not provided', async () => {
|
||||
const dto = new CreateApiKeyRequestDto();
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/api-keys`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201)
|
||||
.then(
|
||||
({
|
||||
body,
|
||||
}: {
|
||||
body: {
|
||||
id: number;
|
||||
value: string;
|
||||
createdAt: Date;
|
||||
};
|
||||
}) => {
|
||||
expect(body).toHaveProperty('id');
|
||||
expect(body).toHaveProperty('value');
|
||||
expect(body).toHaveProperty('createdAt');
|
||||
expect(body.value).toMatch(/^[A-F0-9]{20}$/);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid API key length', async () => {
|
||||
const dto = new CreateApiKeyRequestDto();
|
||||
dto.value = 'ShortKey';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/api-keys`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
const dto = new CreateApiKeyRequestDto();
|
||||
dto.value = 'TestApiKey1234567890';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/api-keys`)
|
||||
.send(dto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/api-keys (GET)', () => {
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateApiKeyRequestDto();
|
||||
dto.value = 'TestApiKeyForList123';
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/api-keys`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
});
|
||||
|
||||
it('should find API keys by project id', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/api-keys`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindApiKeysResponseDto }) => {
|
||||
const responseBody = body;
|
||||
expect(responseBody.items.length).toBeGreaterThan(0);
|
||||
expect(responseBody.items[0]).toHaveProperty('id');
|
||||
expect(responseBody.items[0]).toHaveProperty('value');
|
||||
expect(responseBody.items[0]).toHaveProperty('createdAt');
|
||||
expect(responseBody.items[0]).toHaveProperty('deletedAt');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/api-keys`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/api-keys/:apiKeyId (DELETE)', () => {
|
||||
let apiKeyId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateApiKeyRequestDto();
|
||||
dto.value = 'TestApiKeyForDelete1';
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/api-keys`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
|
||||
apiKeyId = (response.body as { id: number }).id;
|
||||
});
|
||||
|
||||
it('should delete API key', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/api-keys/${apiKeyId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/api-keys/${apiKeyId}`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import {
|
||||
EmailUserSignInRequestDto,
|
||||
EmailUserSignUpRequestDto,
|
||||
EmailVerificationCodeRequestDto,
|
||||
InvitationUserSignUpRequestDto,
|
||||
} from '@/domains/admin/auth/dtos/requests';
|
||||
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import { clearAllEntities } from '@/test-utils/util-functions';
|
||||
|
||||
describe('AuthController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let _dataSource: DataSource;
|
||||
let _authService: AuthService;
|
||||
let tenantService: TenantService;
|
||||
let configService: ConfigService;
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
_dataSource = module.get(getDataSourceToken());
|
||||
_authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
configService = module.get(ConfigService);
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
await tenantService.create(dto);
|
||||
});
|
||||
|
||||
describe('/admin/auth/email/code/verify (POST)', () => {
|
||||
it('should verify email code successfully', async () => {
|
||||
const dto = new EmailVerificationCodeRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.code = '123456';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/email/code/verify')
|
||||
.send(dto)
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/auth/signUp/email (POST)', () => {
|
||||
it('should sign up user with email', async () => {
|
||||
const email = faker.internet.email();
|
||||
|
||||
const dto = new EmailUserSignUpRequestDto();
|
||||
dto.email = email;
|
||||
dto.password = 'password123';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signUp/email')
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 400 for weak password', async () => {
|
||||
const dto = new EmailUserSignUpRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = '123';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signUp/email')
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid email format', async () => {
|
||||
const dto = new EmailUserSignUpRequestDto();
|
||||
dto.email = 'invalid-email';
|
||||
dto.password = 'password123';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signUp/email')
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 409 for duplicate email', async () => {
|
||||
const email = faker.internet.email();
|
||||
const dto = new EmailUserSignUpRequestDto();
|
||||
dto.email = email;
|
||||
dto.password = 'password123';
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signUp/email')
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signUp/email')
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/auth/signIn/email (POST)', () => {
|
||||
it('should sign in user with email and password', async () => {
|
||||
const dto = new EmailUserSignInRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = 'password123';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signIn/email')
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 401 for wrong password', async () => {
|
||||
const dto = new EmailUserSignInRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = 'wrong-password';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signIn/email')
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent email', async () => {
|
||||
const dto = new EmailUserSignInRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = 'password123';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signIn/email')
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid email format', async () => {
|
||||
const dto = new EmailUserSignInRequestDto();
|
||||
dto.email = 'invalid-email';
|
||||
dto.password = 'password123';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signIn/email')
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/auth/signUp/invitation (POST)', () => {
|
||||
it('should sign up user with invitation code', async () => {
|
||||
const dto = new InvitationUserSignUpRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = 'password123';
|
||||
dto.code = 'invitation-code-123';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signUp/invitation')
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid invitation code', async () => {
|
||||
const dto = new InvitationUserSignUpRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = 'password123';
|
||||
dto.code = 'invalid-code';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signUp/invitation')
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 400 for expired invitation code', async () => {
|
||||
const dto = new InvitationUserSignUpRequestDto();
|
||||
dto.email = faker.internet.email();
|
||||
dto.password = 'password123';
|
||||
dto.code = 'expired-code';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/auth/signUp/invitation')
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import { CategoryService } from '@/domains/admin/project/category/category.service';
|
||||
import {
|
||||
CreateCategoryRequestDto,
|
||||
UpdateCategoryRequestDto,
|
||||
} from '@/domains/admin/project/category/dtos/requests';
|
||||
import type { GetAllCategoriesResponseDto } from '@/domains/admin/project/category/dtos/responses';
|
||||
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||
|
||||
describe('CategoryController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
let authService: AuthService;
|
||||
let tenantService: TenantService;
|
||||
let projectService: ProjectService;
|
||||
let _categoryService: CategoryService;
|
||||
let configService: ConfigService;
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
let project: ProjectEntity;
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
projectService = module.get(ProjectService);
|
||||
_categoryService = module.get(CategoryService);
|
||||
configService = module.get(ConfigService);
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
await tenantService.create(dto);
|
||||
|
||||
project = await projectService.create({
|
||||
name: faker.lorem.words(),
|
||||
description: faker.lorem.lines(1),
|
||||
timezone: {
|
||||
countryCode: 'KR',
|
||||
name: 'Asia/Seoul',
|
||||
offset: '+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/categories (POST)', () => {
|
||||
it('should create a category', async () => {
|
||||
const dto = new CreateCategoryRequestDto();
|
||||
dto.name = 'TestCategory';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201)
|
||||
.then(({ body }: { body: { id: number } }) => {
|
||||
expect(body).toHaveProperty('id');
|
||||
expect(typeof body.id).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
const dto = new CreateCategoryRequestDto();
|
||||
dto.name = 'TestCategory';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories`)
|
||||
.send(dto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/categories/search (POST)', () => {
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateCategoryRequestDto();
|
||||
dto.name = 'TestCategoryForList';
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
});
|
||||
|
||||
it('should find categories by project id', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories/search`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
categoryName: 'TestCategory',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(201)
|
||||
.then(({ body }: { body: GetAllCategoriesResponseDto }) => {
|
||||
const responseBody = body;
|
||||
expect(responseBody.items.length).toBeGreaterThan(0);
|
||||
expect(responseBody.items[0]).toHaveProperty('id');
|
||||
expect(responseBody.items[0]).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty list when no categories match search', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories/search`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
categoryName: 'NonExistentCategory',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(201)
|
||||
.then(({ body }: { body: GetAllCategoriesResponseDto }) => {
|
||||
const responseBody = body;
|
||||
expect(responseBody.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories/search`)
|
||||
.send({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/categories/:categoryId (PUT)', () => {
|
||||
let categoryId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateCategoryRequestDto();
|
||||
dto.name = 'TestCategoryForUpdate';
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
|
||||
categoryId = (response.body as { id: number }).id;
|
||||
});
|
||||
|
||||
it('should update category', async () => {
|
||||
const dto = new UpdateCategoryRequestDto();
|
||||
dto.name = 'UpdatedTestCategory';
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/categories/${categoryId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(200);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories/search`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
categoryName: 'UpdatedTestCategory',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(201)
|
||||
.then(({ body }: { body: GetAllCategoriesResponseDto }) => {
|
||||
expect(body.items.length).toBeGreaterThan(0);
|
||||
expect(body.items[0].name).toBe('UpdatedTestCategory');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent category', async () => {
|
||||
const dto = new UpdateCategoryRequestDto();
|
||||
dto.name = 'UpdatedCategory';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/categories/999`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
const dto = new UpdateCategoryRequestDto();
|
||||
dto.name = 'UpdatedCategory';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/categories/${categoryId}`)
|
||||
.send(dto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/categories/:categoryId (DELETE)', () => {
|
||||
let categoryId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateCategoryRequestDto();
|
||||
dto.name = 'TestCategoryForDelete';
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
|
||||
categoryId = (response.body as { id: number }).id;
|
||||
});
|
||||
|
||||
it('should delete category', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/categories/${categoryId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/categories/search`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
categoryName: 'TestCategoryForDelete',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(201)
|
||||
.then(({ body }: { body: GetAllCategoriesResponseDto }) => {
|
||||
expect(body.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 404 when deleting non-existent category', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/categories/999`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/categories/${categoryId}`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import {
|
||||
FieldFormatEnum,
|
||||
FieldPropertyEnum,
|
||||
FieldStatusEnum,
|
||||
} from '@/common/enums';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import {
|
||||
CreateChannelRequestDto,
|
||||
CreateChannelRequestFieldDto,
|
||||
FindChannelsByProjectIdRequestDto,
|
||||
UpdateChannelFieldsRequestDto,
|
||||
UpdateChannelRequestDto,
|
||||
UpdateChannelRequestFieldDto,
|
||||
} from '@/domains/admin/channel/channel/dtos/requests';
|
||||
import type {
|
||||
FindChannelByIdResponseDto,
|
||||
FindChannelsByProjectIdResponseDto,
|
||||
} from '@/domains/admin/channel/channel/dtos/responses';
|
||||
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||
|
||||
describe('ChannelController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
|
||||
let authService: AuthService;
|
||||
let tenantService: TenantService;
|
||||
let projectService: ProjectService;
|
||||
let configService: ConfigService;
|
||||
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
let project: ProjectEntity;
|
||||
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
|
||||
authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
projectService = module.get(ProjectService);
|
||||
configService = module.get(ConfigService);
|
||||
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
await tenantService.create(dto);
|
||||
|
||||
project = await projectService.create({
|
||||
name: faker.lorem.words(),
|
||||
description: faker.lorem.lines(1),
|
||||
timezone: {
|
||||
countryCode: 'KR',
|
||||
name: 'Asia/Seoul',
|
||||
offset: '+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/channels (POST)', () => {
|
||||
it('should create a channel', async () => {
|
||||
const dto = new CreateChannelRequestDto();
|
||||
dto.name = 'TestChannel';
|
||||
|
||||
const fieldDto = new CreateChannelRequestFieldDto();
|
||||
fieldDto.name = 'TestField';
|
||||
fieldDto.key = 'testField';
|
||||
fieldDto.format = FieldFormatEnum.text;
|
||||
fieldDto.property = FieldPropertyEnum.EDITABLE;
|
||||
fieldDto.status = FieldStatusEnum.ACTIVE;
|
||||
|
||||
dto.fields = [fieldDto];
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/channels`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/channels (GET)', () => {
|
||||
it('should find channels by project id', async () => {
|
||||
const dto = new FindChannelsByProjectIdRequestDto();
|
||||
dto.searchText = 'TestChannel';
|
||||
dto.page = 1;
|
||||
dto.limit = 10;
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/channels`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query(dto)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindChannelsByProjectIdResponseDto }) => {
|
||||
expect(body.items.length).toBe(1);
|
||||
expect(body.items[0].name).toBe('TestChannel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/channels/:channelId (GET)', () => {
|
||||
it('should find channel by id', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/channels/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindChannelByIdResponseDto }) => {
|
||||
expect(body.name).toBe('TestChannel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/channels/:channelId (PUT)', () => {
|
||||
it('should update channel', async () => {
|
||||
const dto = new UpdateChannelRequestDto();
|
||||
dto.name = 'TestChannelUpdated';
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/channels/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/channels/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindChannelByIdResponseDto }) => {
|
||||
expect(body.name).toBe('TestChannelUpdated');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/channels/:channelId/fields (PUT)', () => {
|
||||
it('should update channel fields', async () => {
|
||||
const dto = new UpdateChannelFieldsRequestDto();
|
||||
const fieldDto = new UpdateChannelRequestFieldDto();
|
||||
fieldDto.id = 5;
|
||||
fieldDto.format = FieldFormatEnum.text;
|
||||
fieldDto.key = 'testField';
|
||||
fieldDto.name = 'TestFieldUpdated';
|
||||
dto.fields = [fieldDto];
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/channels/1/fields`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(200);
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/channels/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindChannelByIdResponseDto }) => {
|
||||
expect(body.fields.length).toBe(5);
|
||||
expect(body.fields[4].name).toBe('TestFieldUpdated');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 error when update channel field key with special character', async () => {
|
||||
const dto = new UpdateChannelFieldsRequestDto();
|
||||
const fieldDto = new UpdateChannelRequestFieldDto();
|
||||
fieldDto.id = 5;
|
||||
fieldDto.format = FieldFormatEnum.text;
|
||||
fieldDto.key = 'testField!';
|
||||
fieldDto.name = 'testField!';
|
||||
dto.fields = [fieldDto];
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/channels/1/fields`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/channels/:channelId (DELETE)', () => {
|
||||
it('should delete channel', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/channels/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const dto = new FindChannelsByProjectIdRequestDto();
|
||||
dto.page = 1;
|
||||
dto.limit = 10;
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/channels`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query(dto)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindChannelsByProjectIdResponseDto }) => {
|
||||
expect(body.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/channels/1`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel validation tests', () => {
|
||||
it('should return 400 when creating channel with invalid field key', async () => {
|
||||
const dto = new CreateChannelRequestDto();
|
||||
dto.name = 'TestChannel';
|
||||
|
||||
const fieldDto = new CreateChannelRequestFieldDto();
|
||||
fieldDto.name = 'TestField';
|
||||
fieldDto.key = 'invalid-key!@#';
|
||||
fieldDto.format = FieldFormatEnum.text;
|
||||
fieldDto.property = FieldPropertyEnum.EDITABLE;
|
||||
fieldDto.status = FieldStatusEnum.ACTIVE;
|
||||
|
||||
dto.fields = [fieldDto];
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/channels`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 400 when updating channel with invalid data', async () => {
|
||||
const dto = new UpdateChannelRequestDto();
|
||||
dto.name = '';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/channels/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
|
||||
import type { Client } from '@opensearch-project/opensearch';
|
||||
import { DateTime } from 'luxon';
|
||||
import request from 'supertest';
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { FieldFormatEnum, QueryV2ConditionsEnum } from '@/common/enums';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import { ChannelEntity } from '@/domains/admin/channel/channel/channel.entity';
|
||||
import { ChannelService } from '@/domains/admin/channel/channel/channel.service';
|
||||
import { FieldEntity } from '@/domains/admin/channel/field/field.entity';
|
||||
import type { CreateFeedbackDto } from '@/domains/admin/feedback/dtos';
|
||||
import type { FindFeedbacksByChannelIdRequestDtoV2 } from '@/domains/admin/feedback/dtos/requests/find-feedbacks-by-channel-id-request-v2.dto';
|
||||
import type { FindFeedbacksByChannelIdResponseDto } from '@/domains/admin/feedback/dtos/responses';
|
||||
import { FeedbackService } from '@/domains/admin/feedback/feedback.service';
|
||||
import { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import { TenantEntity } from '@/domains/admin/tenant/tenant.entity';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import { getRandomValue } from '@/test-utils/fixtures';
|
||||
import {
|
||||
clearAllEntities,
|
||||
clearEntities,
|
||||
createChannel,
|
||||
createProject,
|
||||
createTenant,
|
||||
signInTestUser,
|
||||
} from '@/test-utils/util-functions';
|
||||
|
||||
describe('FeedbackController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
let authService: AuthService;
|
||||
|
||||
let tenantService: TenantService;
|
||||
let projectService: ProjectService;
|
||||
let channelService: ChannelService;
|
||||
let feedbackService: FeedbackService;
|
||||
let configService: ConfigService;
|
||||
|
||||
let tenantRepo: Repository<TenantEntity>;
|
||||
let projectRepo: Repository<ProjectEntity>;
|
||||
let channelRepo: Repository<ChannelEntity>;
|
||||
let fieldRepo: Repository<FieldEntity>;
|
||||
let osService: Client;
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
let project: ProjectEntity;
|
||||
let channel: ChannelEntity;
|
||||
let fields: FieldEntity[];
|
||||
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
|
||||
authService = module.get(AuthService);
|
||||
|
||||
tenantService = module.get(TenantService);
|
||||
projectService = module.get(ProjectService);
|
||||
channelService = module.get(ChannelService);
|
||||
feedbackService = module.get(FeedbackService);
|
||||
configService = module.get(ConfigService);
|
||||
|
||||
tenantRepo = module.get(getRepositoryToken(TenantEntity));
|
||||
projectRepo = module.get(getRepositoryToken(ProjectEntity));
|
||||
channelRepo = module.get(getRepositoryToken(ChannelEntity));
|
||||
fieldRepo = module.get(getRepositoryToken(FieldEntity));
|
||||
osService = module.get<Client>('OPENSEARCH_CLIENT');
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
await createTenant(tenantService);
|
||||
project = await createProject(projectService);
|
||||
const { id: channelId } = await createChannel(channelService, project);
|
||||
|
||||
channel = await channelService.findById({ channelId });
|
||||
|
||||
fields = await fieldRepo.find({
|
||||
where: { channel: { id: channel.id } },
|
||||
relations: { options: true },
|
||||
});
|
||||
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/channels/:channelId/feedbacks (POST)', () => {
|
||||
it('should create random feedbacks', async () => {
|
||||
const dto: Record<string, string | number | string[] | number[]> = {};
|
||||
fields
|
||||
.filter(
|
||||
({ key }) =>
|
||||
key !== 'id' &&
|
||||
key !== 'issues' &&
|
||||
key !== 'createdAt' &&
|
||||
key !== 'updatedAt',
|
||||
)
|
||||
.forEach(({ key, format, options }) => {
|
||||
dto[key] = getRandomValue(format, options);
|
||||
});
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/channels/${channel.id}/feedbacks`)
|
||||
.set('x-api-key', `${process.env.MASTER_API_KEY}`)
|
||||
.send(dto)
|
||||
.expect(201)
|
||||
.then(
|
||||
async ({
|
||||
body,
|
||||
}: {
|
||||
body: Record<string, any> & { issueNames?: string[] };
|
||||
}) => {
|
||||
expect(body.id).toBeDefined();
|
||||
if (configService.get('opensearch.use')) {
|
||||
const esResult = await osService.get({
|
||||
id: body.id as string,
|
||||
index: channel.id.toString(),
|
||||
});
|
||||
|
||||
['id', 'createdAt', 'updatedAt'].forEach(
|
||||
(field) => delete esResult.body._source?.[field],
|
||||
);
|
||||
expect(dto).toMatchObject(esResult.body._source ?? {});
|
||||
} else {
|
||||
const feedback = await feedbackService.findById({
|
||||
channelId: channel.id,
|
||||
feedbackId: body.id as number,
|
||||
});
|
||||
|
||||
['id', 'createdAt', 'updatedAt', 'issues'].forEach(
|
||||
(field) => delete feedback[field],
|
||||
);
|
||||
expect(dto).toMatchObject(feedback);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/channels/:channelId/feedbacks/search (POST)', () => {
|
||||
it('should return all searched feedbacks', async () => {
|
||||
const dto: CreateFeedbackDto = {
|
||||
channelId: channel.id,
|
||||
data: {},
|
||||
};
|
||||
let availableFieldKey = '';
|
||||
fields
|
||||
.filter(
|
||||
({ key }) =>
|
||||
key !== 'id' &&
|
||||
key !== 'issues' &&
|
||||
key !== 'createdAt' &&
|
||||
key !== 'updatedAt',
|
||||
)
|
||||
.forEach(({ key, format, options }) => {
|
||||
dto.data[key] = getRandomValue(format, options);
|
||||
availableFieldKey = key;
|
||||
});
|
||||
|
||||
dto.data[availableFieldKey] = 'test';
|
||||
|
||||
await feedbackService.create(dto);
|
||||
|
||||
const keywordField = fields.find(
|
||||
({ format }) => format === FieldFormatEnum.keyword,
|
||||
);
|
||||
if (!keywordField) return;
|
||||
|
||||
const findFeedbackDto: FindFeedbacksByChannelIdRequestDtoV2 = {
|
||||
queries: [
|
||||
{
|
||||
key: availableFieldKey,
|
||||
value: 'test',
|
||||
condition: QueryV2ConditionsEnum.IS,
|
||||
},
|
||||
],
|
||||
operator: 'AND',
|
||||
limit: 10,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(
|
||||
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/search`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(findFeedbackDto)
|
||||
.expect(201)
|
||||
.then(({ body }: { body: FindFeedbacksByChannelIdResponseDto }) => {
|
||||
expect(body.meta.itemCount).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/channels/:channelId/feedbacks/:feedbackId (PUT)', () => {
|
||||
it('should update a feedback', async () => {
|
||||
const dto: CreateFeedbackDto = {
|
||||
channelId: channel.id,
|
||||
data: {},
|
||||
};
|
||||
let availableFieldKey = '';
|
||||
fields
|
||||
.filter(
|
||||
({ key }) =>
|
||||
key !== 'id' &&
|
||||
key !== 'issues' &&
|
||||
key !== 'createdAt' &&
|
||||
key !== 'updatedAt',
|
||||
)
|
||||
.forEach(({ key, format, options }) => {
|
||||
dto.data[key] = getRandomValue(format, options);
|
||||
availableFieldKey = key;
|
||||
});
|
||||
|
||||
const feedback = await feedbackService.create(dto);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(
|
||||
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/${feedback.id}`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
[availableFieldKey]: 'test',
|
||||
})
|
||||
.expect(200)
|
||||
.then(async () => {
|
||||
if (configService.get('opensearch.use')) {
|
||||
const esResult = await osService.get({
|
||||
id: feedback.id.toString(),
|
||||
index: channel.id.toString(),
|
||||
});
|
||||
|
||||
['id', 'createdAt', 'updatedAt'].forEach(
|
||||
(field) => delete esResult.body._source?.[field],
|
||||
);
|
||||
|
||||
dto.data[availableFieldKey] = 'test';
|
||||
expect(dto.data).toMatchObject(esResult.body._source ?? {});
|
||||
} else {
|
||||
const updatedFeedback = await feedbackService.findById({
|
||||
channelId: channel.id,
|
||||
feedbackId: feedback.id,
|
||||
});
|
||||
|
||||
['id', 'createdAt', 'updatedAt', 'issues'].forEach(
|
||||
(field) => delete updatedFeedback[field],
|
||||
);
|
||||
|
||||
dto.data[availableFieldKey] = 'test';
|
||||
expect(dto.data).toMatchObject(updatedFeedback);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should update a feedback with special character', async () => {
|
||||
const dto: CreateFeedbackDto = {
|
||||
channelId: channel.id,
|
||||
data: {},
|
||||
};
|
||||
let availableFieldKey = '';
|
||||
fields
|
||||
.filter(
|
||||
({ key }) =>
|
||||
key !== 'id' &&
|
||||
key !== 'issues' &&
|
||||
key !== 'createdAt' &&
|
||||
key !== 'updatedAt',
|
||||
)
|
||||
.forEach(({ key, format, options }) => {
|
||||
dto.data[key] = getRandomValue(format, options);
|
||||
availableFieldKey = key;
|
||||
});
|
||||
|
||||
const feedback = await feedbackService.create(dto);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(
|
||||
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/${feedback.id}`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
[availableFieldKey]: '?',
|
||||
})
|
||||
.expect(200)
|
||||
.then(async () => {
|
||||
if (configService.get('opensearch.use')) {
|
||||
const esResult = await osService.get({
|
||||
id: feedback.id.toString(),
|
||||
index: channel.id.toString(),
|
||||
});
|
||||
|
||||
['id', 'createdAt', 'updatedAt'].forEach(
|
||||
(field) => delete esResult.body._source?.[field],
|
||||
);
|
||||
|
||||
dto.data[availableFieldKey] = '?';
|
||||
expect(dto.data).toMatchObject(esResult.body._source ?? {});
|
||||
} else {
|
||||
const updatedFeedback = await feedbackService.findById({
|
||||
channelId: channel.id,
|
||||
feedbackId: feedback.id,
|
||||
});
|
||||
|
||||
['id', 'createdAt', 'updatedAt', 'issues'].forEach(
|
||||
(field) => delete updatedFeedback[field],
|
||||
);
|
||||
|
||||
dto.data[availableFieldKey] = '?';
|
||||
expect(dto.data).toMatchObject(updatedFeedback);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('old feedback deletion test', () => {
|
||||
it('should create feedbacks and delete feedbacks within specific date range', async () => {
|
||||
const dto: Record<string, string | number | string[] | number[]> = {};
|
||||
fields
|
||||
.filter(
|
||||
({ key }) =>
|
||||
key !== 'id' &&
|
||||
key !== 'issues' &&
|
||||
key !== 'createdAt' &&
|
||||
key !== 'updatedAt',
|
||||
)
|
||||
.forEach(({ key, format, options }) => {
|
||||
dto[key] = getRandomValue(format, options);
|
||||
});
|
||||
|
||||
dto.createdAt = DateTime.now().minus({ month: 7 }).toFormat('yyyy-MM-dd');
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/channels/${channel.id}/feedbacks`)
|
||||
.set('x-api-key', `${process.env.MASTER_API_KEY}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
|
||||
dto.createdAt = DateTime.now().minus({ days: 1 }).toFormat('yyyy-MM-dd');
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/channels/${channel.id}/feedbacks`)
|
||||
.set('x-api-key', `${process.env.MASTER_API_KEY}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
|
||||
await tenantService.deleteOldFeedbacks();
|
||||
|
||||
const findFeedbackDto: FindFeedbacksByChannelIdRequestDtoV2 = {
|
||||
defaultQueries: [
|
||||
{
|
||||
key: 'createdAt',
|
||||
value: {
|
||||
gte: DateTime.now().minus({ years: 1 }).toFormat('yyyy-MM-dd'),
|
||||
lt: DateTime.now().toFormat('yyyy-MM-dd'),
|
||||
},
|
||||
condition: QueryV2ConditionsEnum.BETWEEN,
|
||||
},
|
||||
],
|
||||
operator: 'AND',
|
||||
limit: 10,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(
|
||||
`/admin/projects/${project.id}/channels/${channel.id}/feedbacks/search`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(findFeedbackDto)
|
||||
.expect(201)
|
||||
.then(({ body }: { body: FindFeedbacksByChannelIdResponseDto }) => {
|
||||
expect(body.meta.itemCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await clearEntities([tenantRepo, projectRepo, channelRepo, fieldRepo]);
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { IssueStatusEnum } from '@/common/enums';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import { FindIssuesByProjectIdRequestDto } from '@/domains/admin/project/issue/dtos/requests';
|
||||
import type {
|
||||
FindIssueByIdResponseDto,
|
||||
FindIssuesByProjectIdResponseDto,
|
||||
} from '@/domains/admin/project/issue/dtos/responses';
|
||||
import type { CountIssuesByIdResponseDto } from '@/domains/admin/project/project/dtos/responses';
|
||||
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||
|
||||
describe('IssueController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
|
||||
let authService: AuthService;
|
||||
let tenantService: TenantService;
|
||||
let projectService: ProjectService;
|
||||
let configService: ConfigService;
|
||||
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
let project: ProjectEntity;
|
||||
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
|
||||
authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
projectService = module.get(ProjectService);
|
||||
configService = module.get(ConfigService);
|
||||
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
await tenantService.create(dto);
|
||||
|
||||
project = await projectService.create({
|
||||
name: faker.lorem.words(),
|
||||
description: faker.lorem.lines(1),
|
||||
timezone: {
|
||||
countryCode: 'KR',
|
||||
name: 'Asia/Seoul',
|
||||
offset: '+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/issues (POST)', () => {
|
||||
it('should create an issue', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/issues`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({ name: 'TestIssue' })
|
||||
.expect(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/issues/:issueId (GET)', () => {
|
||||
it('should get an issue', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/issues/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindIssueByIdResponseDto }) => {
|
||||
expect(body.name).toBe('TestIssue');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/issue-count (GET)', () => {
|
||||
it('should return correct issue count', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/issue-count`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: CountIssuesByIdResponseDto }) => {
|
||||
expect(body.total).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/issues/search (POST)', () => {
|
||||
it('should return all searched issues', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/issues`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({ name: 'TestIssue2' })
|
||||
.expect(201);
|
||||
|
||||
const searchDto = new FindIssuesByProjectIdRequestDto();
|
||||
searchDto.query = {
|
||||
searchText: 'TestIssue',
|
||||
};
|
||||
searchDto.page = 1;
|
||||
searchDto.limit = 10;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/issues/search`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(searchDto)
|
||||
.expect(201)
|
||||
.then(({ body }: { body: FindIssuesByProjectIdResponseDto }) => {
|
||||
expect(body).toBeDefined();
|
||||
expect(body).toHaveProperty('items');
|
||||
expect(body.items.length).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/issues/:issueId (PUT)', () => {
|
||||
it('should update an issue', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/issues/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
name: 'TestIssue',
|
||||
description: 'TestIssueUpdated',
|
||||
status: IssueStatusEnum.IN_PROGRESS,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/issues/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindIssueByIdResponseDto }) => {
|
||||
expect(body.description).toBe('TestIssueUpdated');
|
||||
expect(body.status).toBe(IssueStatusEnum.IN_PROGRESS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/issues/:issueId (DELETE)', () => {
|
||||
it('should delete an issue', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/issues/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const searchDto = new FindIssuesByProjectIdRequestDto();
|
||||
searchDto.query = {
|
||||
searchText: 'TestIssue',
|
||||
};
|
||||
searchDto.page = 1;
|
||||
searchDto.limit = 10;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/issues/search`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(searchDto)
|
||||
.expect(201)
|
||||
.then(({ body }: { body: FindIssuesByProjectIdResponseDto }) => {
|
||||
expect(body).toBeDefined();
|
||||
expect(body).toHaveProperty('items');
|
||||
expect(body.items.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/issues (DELETE)', () => {
|
||||
it('should delete many issues', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/issues`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({ issueIds: [2] })
|
||||
.expect(200);
|
||||
|
||||
const searchDto = new FindIssuesByProjectIdRequestDto();
|
||||
searchDto.query = {
|
||||
searchText: 'TestIssue',
|
||||
};
|
||||
searchDto.page = 1;
|
||||
searchDto.limit = 10;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/issues/search`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(searchDto)
|
||||
.expect(201)
|
||||
.then(({ body }: { body: FindIssuesByProjectIdResponseDto }) => {
|
||||
expect(body).toBeDefined();
|
||||
expect(body).toHaveProperty('items');
|
||||
expect(body.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 200 when deleting with invalid issueIds', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/issues`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({ issueIds: [] })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/issues`)
|
||||
.send({ issueIds: [1] })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Issue validation tests', () => {
|
||||
it('should return 400 when updating non-existent issue', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/issues/999`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
name: 'NonExistentIssue',
|
||||
description: 'This should fail',
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 400 when getting non-existent issue', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/issues/999`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import {
|
||||
CreateMemberRequestDto,
|
||||
UpdateMemberRequestDto,
|
||||
} from '@/domains/admin/project/member/dtos/requests';
|
||||
import type { GetAllMemberResponseDto } from '@/domains/admin/project/member/dtos/responses';
|
||||
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import { PermissionEnum } from '@/domains/admin/project/role/permission.enum';
|
||||
import type { RoleEntity } from '@/domains/admin/project/role/role.entity';
|
||||
import { RoleService } from '@/domains/admin/project/role/role.service';
|
||||
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import {
|
||||
UserStateEnum,
|
||||
UserTypeEnum,
|
||||
} from '@/domains/admin/user/entities/enums';
|
||||
import { UserEntity } from '@/domains/admin/user/entities/user.entity';
|
||||
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||
|
||||
describe('MemberController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
let authService: AuthService;
|
||||
let tenantService: TenantService;
|
||||
let projectService: ProjectService;
|
||||
let roleService: RoleService;
|
||||
|
||||
let configService: ConfigService;
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
let project: ProjectEntity;
|
||||
let role: RoleEntity;
|
||||
let user: UserEntity;
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
projectService = module.get(ProjectService);
|
||||
roleService = module.get(RoleService);
|
||||
configService = module.get(ConfigService);
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
await tenantService.create(dto);
|
||||
|
||||
project = await projectService.create({
|
||||
name: faker.lorem.words(),
|
||||
description: faker.lorem.lines(1),
|
||||
timezone: {
|
||||
countryCode: 'KR',
|
||||
name: 'Asia/Seoul',
|
||||
offset: '+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
role = await roleService.create({
|
||||
projectId: project.id,
|
||||
name: 'TestRole',
|
||||
permissions: [
|
||||
PermissionEnum.feedback_download_read,
|
||||
PermissionEnum.feedback_update,
|
||||
],
|
||||
});
|
||||
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
|
||||
const userRepo = dataSource.getRepository(UserEntity);
|
||||
user = await userRepo.save({
|
||||
email: faker.internet.email(),
|
||||
state: UserStateEnum.Active,
|
||||
hashPassword: faker.internet.password(),
|
||||
type: UserTypeEnum.GENERAL,
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/members (POST)', () => {
|
||||
afterEach(async () => {
|
||||
await dataSource.query('DELETE FROM members WHERE role_id = ?', [
|
||||
role.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should create a member', async () => {
|
||||
const dto = new CreateMemberRequestDto();
|
||||
dto.userId = user.id;
|
||||
dto.roleId = role.id;
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it('should return 400 for duplicate member', async () => {
|
||||
const dto = new CreateMemberRequestDto();
|
||||
dto.userId = user.id;
|
||||
dto.roleId = role.id;
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 400 for non-existent user', async () => {
|
||||
const dto = new CreateMemberRequestDto();
|
||||
dto.userId = 999;
|
||||
dto.roleId = role.id;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent role', async () => {
|
||||
const dto = new CreateMemberRequestDto();
|
||||
dto.userId = user.id;
|
||||
dto.roleId = 999;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
const dto = new CreateMemberRequestDto();
|
||||
dto.userId = user.id;
|
||||
dto.roleId = role.id;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members`)
|
||||
.send(dto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/members/search (POST)', () => {
|
||||
afterEach(async () => {
|
||||
await dataSource.query('DELETE FROM members WHERE role_id = ?', [
|
||||
role.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should find members by project id', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
userId: user.id,
|
||||
roleId: role.id,
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members/search`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
queries: [
|
||||
{
|
||||
key: 'email',
|
||||
value: user.email,
|
||||
condition: 'LIKE',
|
||||
},
|
||||
],
|
||||
operator: 'AND',
|
||||
limit: 10,
|
||||
page: 1,
|
||||
})
|
||||
.expect(201)
|
||||
.then(({ body }: { body: GetAllMemberResponseDto }) => {
|
||||
const responseBody = body;
|
||||
expect(responseBody.items.length).toBeGreaterThan(0);
|
||||
expect(responseBody.items[0]).toHaveProperty('id');
|
||||
expect(responseBody.items[0]).toHaveProperty('user');
|
||||
expect(responseBody.items[0]).toHaveProperty('role');
|
||||
expect(responseBody.items[0].user).toHaveProperty('email');
|
||||
expect(responseBody.items[0].role).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty list when no members match search', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members/search`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({
|
||||
queries: [
|
||||
{
|
||||
key: 'email',
|
||||
value: 'NonExistentUser',
|
||||
condition: 'LIKE',
|
||||
},
|
||||
],
|
||||
operator: 'AND',
|
||||
limit: 10,
|
||||
page: 1,
|
||||
})
|
||||
.expect(201)
|
||||
.then(({ body }: { body: GetAllMemberResponseDto }) => {
|
||||
const responseBody = body;
|
||||
expect(responseBody.items.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members/search`)
|
||||
.send({
|
||||
queries: [],
|
||||
operator: 'AND',
|
||||
limit: 10,
|
||||
page: 1,
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/members/:memberId (GET)', () => {
|
||||
afterEach(async () => {
|
||||
await dataSource.query('DELETE FROM members WHERE role_id = ?', [
|
||||
role.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent member', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/members/999`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/members/:memberId (PUT)', () => {
|
||||
let memberId: number;
|
||||
let newRole: RoleEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
newRole = await roleService.create({
|
||||
projectId: project.id,
|
||||
name: `NewTestRole_${Date.now()}`,
|
||||
permissions: [PermissionEnum.feedback_download_read],
|
||||
});
|
||||
|
||||
const dto = new CreateMemberRequestDto();
|
||||
dto.userId = user.id;
|
||||
dto.roleId = role.id;
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
|
||||
const allMembers: { id: number }[] = await dataSource.query(
|
||||
'SELECT id FROM members ORDER BY id DESC LIMIT 1',
|
||||
);
|
||||
memberId = allMembers.length > 0 ? allMembers[0].id : 1;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await dataSource.query(
|
||||
'DELETE FROM members WHERE role_id = ? OR role_id = ?',
|
||||
[role.id, newRole.id],
|
||||
);
|
||||
await dataSource.query('DELETE FROM roles WHERE id = ?', [newRole.id]);
|
||||
});
|
||||
|
||||
it('should update member role', async () => {
|
||||
const dto = new UpdateMemberRequestDto();
|
||||
dto.roleId = newRole.id;
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/members/${memberId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent role', async () => {
|
||||
const dto = new UpdateMemberRequestDto();
|
||||
dto.roleId = 999;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/members/${memberId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 400 for non-existent member', async () => {
|
||||
const dto = new UpdateMemberRequestDto();
|
||||
dto.roleId = newRole.id;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/members/999`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
const dto = new UpdateMemberRequestDto();
|
||||
dto.roleId = newRole.id;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/members/${memberId}`)
|
||||
.send(dto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/members/:memberId (DELETE)', () => {
|
||||
let memberId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateMemberRequestDto();
|
||||
dto.userId = user.id;
|
||||
dto.roleId = role.id;
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/members`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
|
||||
const allMembers: { id: number }[] = await dataSource.query(
|
||||
'SELECT id FROM members ORDER BY id DESC LIMIT 1',
|
||||
);
|
||||
memberId = allMembers.length > 0 ? allMembers[0].id : 1;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await dataSource.query('DELETE FROM members WHERE role_id = ?', [
|
||||
role.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should delete member', async () => {
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/members/${memberId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should return 200 when deleting non-existent member', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/members/999`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/members/${memberId}`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import { ChannelService } from '@/domains/admin/channel/channel/channel.service';
|
||||
import { FieldEntity } from '@/domains/admin/channel/field/field.entity';
|
||||
import { FeedbackService } from '@/domains/admin/feedback/feedback.service';
|
||||
import {
|
||||
CreateProjectRequestDto,
|
||||
FindProjectsRequestDto,
|
||||
UpdateProjectRequestDto,
|
||||
} from '@/domains/admin/project/project/dtos/requests';
|
||||
import type {
|
||||
CountFeedbacksByIdResponseDto,
|
||||
FindProjectByIdResponseDto,
|
||||
FindProjectsResponseDto,
|
||||
} from '@/domains/admin/project/project/dtos/responses';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import {
|
||||
clearAllEntities,
|
||||
createChannel,
|
||||
createFeedback,
|
||||
signInTestUser,
|
||||
} from '@/test-utils/util-functions';
|
||||
|
||||
describe('ProjectController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
let authService: AuthService;
|
||||
|
||||
let tenantService: TenantService;
|
||||
let projectService: ProjectService;
|
||||
let channelService: ChannelService;
|
||||
let feedbackService: FeedbackService;
|
||||
let configService: ConfigService;
|
||||
|
||||
let fieldRepo: Repository<FieldEntity>;
|
||||
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
|
||||
authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
projectService = module.get(ProjectService);
|
||||
channelService = module.get(ChannelService);
|
||||
feedbackService = module.get(FeedbackService);
|
||||
configService = module.get(ConfigService);
|
||||
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
fieldRepo = module.get(getRepositoryToken(FieldEntity));
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
await tenantService.create(dto);
|
||||
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
});
|
||||
|
||||
describe('/admin/projects (POST)', () => {
|
||||
it('should create a project', async () => {
|
||||
const dto = new CreateProjectRequestDto();
|
||||
dto.name = 'TestProject';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects (GET)', () => {
|
||||
it('should find projects', async () => {
|
||||
const dto = new FindProjectsRequestDto();
|
||||
dto.limit = 10;
|
||||
dto.page = 1;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query(dto)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindProjectsResponseDto }) => {
|
||||
expect(body.items.length).toEqual(1);
|
||||
expect(body.items[0].name).toEqual('TestProject');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId (GET)', () => {
|
||||
it('should find a project by id', async () => {
|
||||
const dto = new FindProjectsRequestDto();
|
||||
dto.limit = 10;
|
||||
dto.page = 1;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query(dto)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindProjectByIdResponseDto }) => {
|
||||
expect(body.name).toEqual('TestProject');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/feedback-count (GET)', () => {
|
||||
it('should count feedbacks by project id', async () => {
|
||||
const project = await projectService.findById({ projectId: 1 });
|
||||
const channel = await createChannel(channelService, project);
|
||||
|
||||
const fields = await fieldRepo.find({
|
||||
where: { channel: { id: channel.id } },
|
||||
relations: { options: true },
|
||||
});
|
||||
|
||||
await createFeedback(fields, channel.id, feedbackService);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/1/feedback-count`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: CountFeedbacksByIdResponseDto }) => {
|
||||
expect(body.total).toEqual(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId (PUT)', () => {
|
||||
it('should update a project', async () => {
|
||||
const dto = new UpdateProjectRequestDto();
|
||||
dto.name = 'UpdatedTestProject';
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(200);
|
||||
|
||||
const findDto = new FindProjectsRequestDto();
|
||||
findDto.limit = 10;
|
||||
findDto.page = 1;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query(findDto)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindProjectsResponseDto }) => {
|
||||
expect(body.items.length).toEqual(1);
|
||||
expect(body.items[0].name).toEqual('UpdatedTestProject');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId (DELETE)', () => {
|
||||
it('should delete a project', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/1`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const findDto = new FindProjectsRequestDto();
|
||||
findDto.limit = 10;
|
||||
findDto.page = 1;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query(findDto)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: FindProjectsResponseDto }) => {
|
||||
expect(body.items.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import {
|
||||
CreateRoleRequestDto,
|
||||
UpdateRoleRequestDto,
|
||||
} from '@/domains/admin/project/role/dtos/requests';
|
||||
import type { GetAllRolesResponseDto } from '@/domains/admin/project/role/dtos/responses';
|
||||
import type { GetAllRolesResponseRoleDto } from '@/domains/admin/project/role/dtos/responses/get-all-roles-response.dto';
|
||||
import { PermissionEnum } from '@/domains/admin/project/role/permission.enum';
|
||||
import { RoleService } from '@/domains/admin/project/role/role.service';
|
||||
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||
|
||||
describe('RoleController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
let authService: AuthService;
|
||||
let tenantService: TenantService;
|
||||
let projectService: ProjectService;
|
||||
let _roleService: RoleService;
|
||||
let configService: ConfigService;
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
let project: ProjectEntity;
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
projectService = module.get(ProjectService);
|
||||
_roleService = module.get(RoleService);
|
||||
configService = module.get(ConfigService);
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
await tenantService.create(dto);
|
||||
|
||||
project = await projectService.create({
|
||||
name: faker.lorem.words(),
|
||||
description: faker.lorem.lines(1),
|
||||
timezone: {
|
||||
countryCode: 'KR',
|
||||
name: 'Asia/Seoul',
|
||||
offset: '+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/roles (POST)', () => {
|
||||
it('should create a role', async () => {
|
||||
const dto = new CreateRoleRequestDto();
|
||||
dto.name = 'TestRole';
|
||||
dto.permissions = [
|
||||
PermissionEnum.feedback_download_read,
|
||||
PermissionEnum.feedback_update,
|
||||
];
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
|
||||
const listResponse = await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query({
|
||||
searchText: 'TestRole',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const roles = (listResponse.body as GetAllRolesResponseDto).roles;
|
||||
expect(roles.length).toBeGreaterThan(0);
|
||||
|
||||
const createdRole = roles.find(
|
||||
(role: GetAllRolesResponseRoleDto) => role.name === 'TestRole',
|
||||
);
|
||||
expect(createdRole).toBeDefined();
|
||||
expect(createdRole?.name).toBe('TestRole');
|
||||
expect(createdRole?.permissions).toEqual([
|
||||
'feedback_download_read',
|
||||
'feedback_update',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return 400 for empty role name', async () => {
|
||||
const dto = new CreateRoleRequestDto();
|
||||
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid permissions', async () => {
|
||||
const dto = new CreateRoleRequestDto();
|
||||
dto.name = 'TestRole';
|
||||
dto.permissions = [];
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
const dto = new CreateRoleRequestDto();
|
||||
dto.name = 'TestRole';
|
||||
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/roles`)
|
||||
.send(dto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/roles (GET)', () => {
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateRoleRequestDto();
|
||||
dto.name = 'TestRoleForList';
|
||||
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
});
|
||||
|
||||
it('should find roles by project id', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query({
|
||||
searchText: 'TestRole',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(200)
|
||||
.then(({ body }: { body: GetAllRolesResponseDto }) => {
|
||||
const responseBody = body;
|
||||
expect(responseBody.roles.length).toBeGreaterThan(0);
|
||||
expect(responseBody.roles[0]).toHaveProperty('id');
|
||||
expect(responseBody.roles[0]).toHaveProperty('name');
|
||||
expect(responseBody.roles[0]).toHaveProperty('permissions');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/roles`)
|
||||
.query({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/roles/:roleId (PUT)', () => {
|
||||
let roleId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dto = new CreateRoleRequestDto();
|
||||
dto.name = 'TestRoleForUpdate';
|
||||
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const roles = (response.body as GetAllRolesResponseDto).roles;
|
||||
const createdRole = roles.find(
|
||||
(role: GetAllRolesResponseRoleDto) => role.name === 'TestRoleForUpdate',
|
||||
);
|
||||
if (!createdRole) {
|
||||
throw new Error('TestRoleForUpdate not found');
|
||||
}
|
||||
roleId = createdRole.id;
|
||||
});
|
||||
|
||||
it('should update role', async () => {
|
||||
const dto = new UpdateRoleRequestDto();
|
||||
dto.name = 'UpdatedTestRole';
|
||||
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(204);
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: GetAllRolesResponseDto }) => {
|
||||
const roles = body.roles;
|
||||
const updatedRole = roles.find(
|
||||
(role: GetAllRolesResponseRoleDto) =>
|
||||
role.name === 'UpdatedTestRole',
|
||||
);
|
||||
if (!updatedRole) {
|
||||
throw new Error('UpdatedTestRole not found');
|
||||
}
|
||||
expect(updatedRole.name).toBe('UpdatedTestRole');
|
||||
expect(updatedRole.permissions).toEqual(['feedback_download_read']);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for empty role name', async () => {
|
||||
const dto = new UpdateRoleRequestDto();
|
||||
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
const dto = new UpdateRoleRequestDto();
|
||||
dto.name = 'UpdatedRole';
|
||||
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||
.send(dto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/roles/:roleId (DELETE)', () => {
|
||||
let roleId: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dto = new CreateRoleRequestDto();
|
||||
dto.name = 'TestRoleForDelete';
|
||||
dto.permissions = [PermissionEnum.feedback_download_read];
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const roles = (response.body as GetAllRolesResponseDto).roles;
|
||||
const createdRole = roles.find(
|
||||
(role: GetAllRolesResponseRoleDto) => role.name === 'TestRoleForDelete',
|
||||
);
|
||||
if (!createdRole) {
|
||||
throw new Error('TestRoleForDelete not found');
|
||||
}
|
||||
roleId = createdRole.id;
|
||||
});
|
||||
|
||||
it('should delete role', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const roles = (response.body as GetAllRolesResponseDto).roles;
|
||||
const deletedRole = roles.find(
|
||||
(role: GetAllRolesResponseRoleDto) => role.name === 'TestRoleForDelete',
|
||||
);
|
||||
expect(deletedRole).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/roles/${roleId}`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import {
|
||||
SetupTenantRequestDto,
|
||||
UpdateTenantRequestDto,
|
||||
} from '@/domains/admin/tenant/dtos/requests';
|
||||
import type { GetTenantResponseDto } from '@/domains/admin/tenant/dtos/responses';
|
||||
import { TenantEntity } from '@/domains/admin/tenant/tenant.entity';
|
||||
import { UserEntity } from '@/domains/admin/user/entities/user.entity';
|
||||
import {
|
||||
clearAllEntities,
|
||||
clearEntities,
|
||||
signInTestUser,
|
||||
} from '@/test-utils/util-functions';
|
||||
import { HttpStatusCode } from '@/types/http-status';
|
||||
|
||||
describe('TenantController (integration)', () => {
|
||||
let module: TestingModule;
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
let tenantRepo: Repository<TenantEntity>;
|
||||
let userRepo: Repository<UserEntity>;
|
||||
|
||||
let authService: AuthService;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
module = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
tenantRepo = dataSource.getRepository(TenantEntity);
|
||||
userRepo = dataSource.getRepository(UserEntity);
|
||||
|
||||
authService = module.get(AuthService);
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAllEntities(module);
|
||||
});
|
||||
|
||||
describe('/admin/tenants (POST)', () => {
|
||||
it('should create a tenant', async () => {
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
|
||||
return await request(app.getHttpServer() as Server)
|
||||
.post('/admin/tenants')
|
||||
.send(dto)
|
||||
.expect(201)
|
||||
.then(async () => {
|
||||
const tenants = await tenantRepo.find();
|
||||
expect(tenants).toHaveLength(1);
|
||||
const [tenant] = tenants;
|
||||
for (const key in dto) {
|
||||
if (['email', 'password'].includes(key)) continue;
|
||||
const value = dto[key] as string;
|
||||
expect(tenant[key]).toEqual(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
it('should return bad request since tenant is already exists', async () => {
|
||||
await tenantRepo.save({
|
||||
siteName: faker.string.sample(),
|
||||
allowDomains: [],
|
||||
});
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post('/admin/tenants')
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await clearEntities([tenantRepo]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/tenants (PUT)', () => {
|
||||
let tenant: TenantEntity;
|
||||
let accessToken: string;
|
||||
beforeEach(async () => {
|
||||
tenant = await tenantRepo.save({
|
||||
siteName: faker.string.sample(),
|
||||
allowDomains: [],
|
||||
});
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
});
|
||||
it('should update a tenant', async () => {
|
||||
const dto = new UpdateTenantRequestDto();
|
||||
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.allowDomains = [];
|
||||
|
||||
return await request(app.getHttpServer() as Server)
|
||||
.put('/admin/tenants')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(204)
|
||||
.then(async () => {
|
||||
const updatedTenant = await tenantRepo.findOne({
|
||||
where: { id: tenant.id },
|
||||
});
|
||||
expect(updatedTenant?.siteName).toEqual(dto.siteName);
|
||||
expect(updatedTenant?.allowDomains).toEqual(dto.allowDomains);
|
||||
});
|
||||
});
|
||||
it('should fail to find a tenant', async () => {
|
||||
await clearEntities([tenantRepo]);
|
||||
|
||||
const dto = new UpdateTenantRequestDto();
|
||||
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.allowDomains = [];
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put('/admin/tenants')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
it('should reject the request when unauthorized', async () => {
|
||||
const dto = new UpdateTenantRequestDto();
|
||||
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.allowDomains = [];
|
||||
|
||||
return await request(app.getHttpServer() as Server)
|
||||
.put('/admin/tenants')
|
||||
.send(dto)
|
||||
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/tenants (GET)', () => {
|
||||
const dto = new SetupTenantRequestDto();
|
||||
beforeEach(async () => {
|
||||
await clearEntities([tenantRepo, userRepo]);
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post('/admin/tenants')
|
||||
.send(dto);
|
||||
});
|
||||
|
||||
it('should find a tenant', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get('/admin/tenants')
|
||||
.expect(200)
|
||||
.expect(({ body }) => {
|
||||
expect(dto.siteName).toEqual((body as GetTenantResponseDto).siteName);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import { DateTime } from 'luxon';
|
||||
import request from 'supertest';
|
||||
import type { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import { RoleEntity } from '@/domains/admin/project/role/role.entity';
|
||||
import { TenantEntity } from '@/domains/admin/tenant/tenant.entity';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import type { UserDto } from '@/domains/admin/user/dtos';
|
||||
import type { GetAllUserResponseDto } from '@/domains/admin/user/dtos/responses/get-all-user-response.dto';
|
||||
import { UserStateEnum } from '@/domains/admin/user/entities/enums';
|
||||
import { UserEntity } from '@/domains/admin/user/entities/user.entity';
|
||||
import {
|
||||
clearEntities,
|
||||
createTenant,
|
||||
getRandomEnumValue,
|
||||
signInTestUser,
|
||||
} from '@/test-utils/util-functions';
|
||||
import { HttpStatusCode } from '@/types/http-status';
|
||||
|
||||
describe('UserController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
let userRepo: Repository<UserEntity>;
|
||||
let roleRepo: Repository<RoleEntity>;
|
||||
let tenantRepo: Repository<TenantEntity>;
|
||||
|
||||
let tenantService: TenantService;
|
||||
|
||||
let authService: AuthService;
|
||||
beforeAll(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ transform: true, whitelist: true }),
|
||||
);
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
userRepo = dataSource.getRepository(UserEntity);
|
||||
roleRepo = dataSource.getRepository(RoleEntity);
|
||||
tenantRepo = dataSource.getRepository(TenantEntity);
|
||||
authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
|
||||
await clearEntities([tenantRepo, userRepo, roleRepo]);
|
||||
|
||||
await createTenant(tenantService);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await dataSource.destroy();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
let total: number;
|
||||
let userEntities: UserEntity[];
|
||||
let accessToken: string;
|
||||
let ownerUser: UserEntity;
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearEntities([userRepo, roleRepo]);
|
||||
|
||||
const length = faker.number.int({ min: 3, max: 8 });
|
||||
|
||||
userEntities = (
|
||||
await userRepo.save(
|
||||
Array.from({ length: length }).map(() => ({
|
||||
email: faker.internet.email(),
|
||||
state: getRandomEnumValue(UserStateEnum),
|
||||
hashPassword: faker.internet.password(),
|
||||
})),
|
||||
)
|
||||
).sort((a, b) =>
|
||||
DateTime.fromJSDate(b.createdAt)
|
||||
.diff(DateTime.fromJSDate(a.createdAt))
|
||||
.as('milliseconds'),
|
||||
);
|
||||
|
||||
const { jwt, user } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
ownerUser = user;
|
||||
|
||||
total = length + 1;
|
||||
});
|
||||
|
||||
describe('/admin/users (GET)', () => {
|
||||
it('should return all users', async () => {
|
||||
const expectUsers = userEntities
|
||||
.concat(ownerUser)
|
||||
.sort((a, b) =>
|
||||
DateTime.fromJSDate(a.createdAt)
|
||||
.diff(DateTime.fromJSDate(b.createdAt))
|
||||
.as('milliseconds'),
|
||||
)
|
||||
.map(({ id, email }) => ({
|
||||
id,
|
||||
email,
|
||||
}))
|
||||
.slice(0, 10);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get('/admin/users')
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(HttpStatusCode.OK)
|
||||
.expect(({ body }) => {
|
||||
expect(body).toHaveProperty('items');
|
||||
expect(body).toHaveProperty('meta');
|
||||
|
||||
const { items, meta } = body as GetAllUserResponseDto;
|
||||
[
|
||||
'name',
|
||||
'department',
|
||||
'type',
|
||||
'members',
|
||||
'createdAt',
|
||||
'signUpMethod',
|
||||
].forEach((field) => items.forEach((item) => delete item[field]));
|
||||
expect(items).toEqual(expectUsers);
|
||||
expect(meta.totalItems).toEqual(total);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return unauthorized status code', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get('/admin/users')
|
||||
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/users (DELETE)', () => {
|
||||
it('should return empty result', async () => {
|
||||
const ids = faker.helpers.arrayElements(userEntities).map((v) => v.id);
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/users`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send({ ids })
|
||||
.expect(HttpStatusCode.OK)
|
||||
.then(async () => {
|
||||
for (const id of ids) {
|
||||
const result = await userRepo.findOneBy({ id });
|
||||
expect(result).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should return unauthorized status code', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/users`)
|
||||
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/users/:id (GET)', () => {
|
||||
it('check signed-in user', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/users/${ownerUser.id}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200)
|
||||
.expect(({ body }) => {
|
||||
expect((body as UserDto).id).toEqual(ownerUser.id);
|
||||
expect((body as UserDto).email).toEqual(ownerUser.email);
|
||||
});
|
||||
});
|
||||
it('should return unauthorized status code', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/users/${ownerUser.id}`)
|
||||
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/users/:id (DELETE)', () => {
|
||||
it('should return empty result', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/users/${ownerUser.id}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(HttpStatusCode.OK)
|
||||
.then(async () => {
|
||||
const result = await userRepo.findOneBy({ id: ownerUser.id });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
it('should return unauthorized status code', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/users/${faker.number.int()}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||
});
|
||||
it('should return unauthorized status code', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/users/${ownerUser.id}`)
|
||||
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/users/:id/roles (GET)', () => {
|
||||
it('should return OK', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/users/${ownerUser.id}/roles`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(HttpStatusCode.OK);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/users/:id/roles (PUT)', () => {
|
||||
it('should return unauthorized status code', async () => {
|
||||
const role = await roleRepo.save({
|
||||
name: faker.string.sample(),
|
||||
permissions: [],
|
||||
});
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/users/${ownerUser.id}`)
|
||||
.send({ roleId: role.id })
|
||||
.expect(HttpStatusCode.UNAUTHORIZED);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* 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 { Server } from 'net';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import request from 'supertest';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import { initializeTransactionalContext } from 'typeorm-transactional';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import {
|
||||
EventStatusEnum,
|
||||
EventTypeEnum,
|
||||
WebhookStatusEnum,
|
||||
} from '@/common/enums';
|
||||
import { OpensearchRepository } from '@/common/repositories';
|
||||
import { AuthService } from '@/domains/admin/auth/auth.service';
|
||||
import type { ProjectEntity } from '@/domains/admin/project/project/project.entity';
|
||||
import { ProjectService } from '@/domains/admin/project/project/project.service';
|
||||
import {
|
||||
CreateWebhookRequestDto,
|
||||
UpdateWebhookRequestDto,
|
||||
} from '@/domains/admin/project/webhook/dtos/requests';
|
||||
import type {
|
||||
GetWebhookByIdResponseDto,
|
||||
GetWebhooksByProjectIdResponseDto,
|
||||
} from '@/domains/admin/project/webhook/dtos/responses';
|
||||
import { SetupTenantRequestDto } from '@/domains/admin/tenant/dtos/requests';
|
||||
import { TenantService } from '@/domains/admin/tenant/tenant.service';
|
||||
import { clearAllEntities, signInTestUser } from '@/test-utils/util-functions';
|
||||
|
||||
describe('WebhookController (integration)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let dataSource: DataSource;
|
||||
let authService: AuthService;
|
||||
let tenantService: TenantService;
|
||||
let projectService: ProjectService;
|
||||
let configService: ConfigService;
|
||||
let opensearchRepository: OpensearchRepository;
|
||||
|
||||
let project: ProjectEntity;
|
||||
let accessToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
initializeTransactionalContext();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication();
|
||||
await app.init();
|
||||
|
||||
dataSource = module.get(getDataSourceToken());
|
||||
authService = module.get(AuthService);
|
||||
tenantService = module.get(TenantService);
|
||||
projectService = module.get(ProjectService);
|
||||
configService = module.get(ConfigService);
|
||||
opensearchRepository = module.get(OpensearchRepository);
|
||||
|
||||
await clearAllEntities(module);
|
||||
if (configService.get('opensearch.use')) {
|
||||
await opensearchRepository.deleteAllIndexes();
|
||||
}
|
||||
|
||||
const dto = new SetupTenantRequestDto();
|
||||
dto.siteName = faker.string.sample();
|
||||
dto.password = '12345678';
|
||||
await tenantService.create(dto);
|
||||
|
||||
project = await projectService.create({
|
||||
name: faker.lorem.words(),
|
||||
description: faker.lorem.lines(1),
|
||||
timezone: {
|
||||
countryCode: 'KR',
|
||||
name: 'Asia/Seoul',
|
||||
offset: '+09:00',
|
||||
},
|
||||
});
|
||||
|
||||
const { jwt } = await signInTestUser(dataSource, authService);
|
||||
accessToken = jwt.accessToken;
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/webhooks (POST)', () => {
|
||||
it('should create a webhook', async () => {
|
||||
const dto = new CreateWebhookRequestDto();
|
||||
dto.name = 'TestWebhook';
|
||||
dto.url = 'https://example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(201);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(200)
|
||||
.then(({ body }: { body: GetWebhooksByProjectIdResponseDto }) => {
|
||||
expect(body.items[0].name).toBe('TestWebhook');
|
||||
expect(body.items[0].url).toBe('https://example.com/webhook');
|
||||
expect(body.items[0].events).toHaveLength(1);
|
||||
expect(body.items[0].status).toBe(WebhookStatusEnum.ACTIVE);
|
||||
expect(body.items[0].createdAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 400 for empty webhook name', async () => {
|
||||
const dto = new CreateWebhookRequestDto();
|
||||
dto.url = 'https://example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 400 for invalid URL format', async () => {
|
||||
const dto = new CreateWebhookRequestDto();
|
||||
dto.name = 'TestWebhook';
|
||||
dto.url = 'invalid-url';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 400 for empty events array', async () => {
|
||||
const dto = new CreateWebhookRequestDto();
|
||||
dto.name = 'TestWebhook';
|
||||
dto.url = 'https://example.com/webhook';
|
||||
dto.events = [];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
const dto = new CreateWebhookRequestDto();
|
||||
dto.name = 'TestWebhook';
|
||||
dto.url = 'https://example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/webhooks`)
|
||||
.send(dto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/webhooks (GET)', () => {
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateWebhookRequestDto();
|
||||
dto.name = 'TestWebhookForList';
|
||||
dto.url = 'https://example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
});
|
||||
|
||||
it('should find webhooks by project id', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.query({
|
||||
searchText: 'TestWebhook',
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(200)
|
||||
.then(({ body }: { body: GetWebhooksByProjectIdResponseDto }) => {
|
||||
const responseBody = body;
|
||||
expect(responseBody.items.length).toBeGreaterThan(0);
|
||||
expect(responseBody.items[0]).toHaveProperty('id');
|
||||
expect(responseBody.items[0]).toHaveProperty('name');
|
||||
expect(responseBody.items[0]).toHaveProperty('url');
|
||||
expect(responseBody.items[0]).toHaveProperty('events');
|
||||
expect(responseBody.items[0]).toHaveProperty('status');
|
||||
expect(responseBody.items[0]).toHaveProperty('createdAt');
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/webhooks`)
|
||||
.query({
|
||||
page: 1,
|
||||
limit: 10,
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/webhooks/:webhookId (GET)', () => {
|
||||
let webhookId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateWebhookRequestDto();
|
||||
dto.name = 'TestWebhookForGet';
|
||||
dto.url = 'https://example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
|
||||
webhookId = (response.body as { id: number }).id;
|
||||
});
|
||||
|
||||
it('should find webhook by id', async () => {
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as GetWebhookByIdResponseDto[];
|
||||
expect(response.body).toBeDefined();
|
||||
expect(body[0].id).toBe(webhookId);
|
||||
expect(body[0].name).toBe('TestWebhookForGet');
|
||||
expect(body[0].url).toBe('https://example.com/webhook');
|
||||
expect(body[0].events).toHaveLength(1);
|
||||
expect(body[0].status).toBe(WebhookStatusEnum.ACTIVE);
|
||||
expect(body[0].createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/webhooks/:webhookId (PUT)', () => {
|
||||
let webhookId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateWebhookRequestDto();
|
||||
dto.name = 'TestWebhookForUpdate';
|
||||
dto.url = 'https://example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
|
||||
webhookId = (response.body as { id: number }).id;
|
||||
});
|
||||
|
||||
it('should update webhook', async () => {
|
||||
const dto = new UpdateWebhookRequestDto();
|
||||
dto.name = 'UpdatedTestWebhook';
|
||||
dto.url = 'https://updated-example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
dto.token = null;
|
||||
|
||||
await request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(200);
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toBeDefined();
|
||||
const body = response.body as GetWebhookByIdResponseDto[];
|
||||
expect(body[0].name).toBe('UpdatedTestWebhook');
|
||||
expect(body[0].url).toBe('https://updated-example.com/webhook');
|
||||
expect(body[0].events).toHaveLength(1);
|
||||
expect(body[0].events[0].type).toBe(EventTypeEnum.FEEDBACK_CREATION);
|
||||
expect(body[0].status).toBe(WebhookStatusEnum.ACTIVE);
|
||||
});
|
||||
|
||||
it('should update webhook with empty name', async () => {
|
||||
const dto = new UpdateWebhookRequestDto();
|
||||
dto.name = '';
|
||||
dto.url = 'https://updated-example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
dto.token = null;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent webhook', async () => {
|
||||
const dto = new UpdateWebhookRequestDto();
|
||||
dto.name = 'UpdatedWebhook';
|
||||
dto.url = 'https://updated-example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
dto.token = null;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/webhooks/999`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
const dto = new UpdateWebhookRequestDto();
|
||||
dto.name = 'UpdatedWebhook';
|
||||
dto.url = 'https://updated-example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.put(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||
.send(dto)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/admin/projects/:projectId/webhooks/:webhookId (DELETE)', () => {
|
||||
let webhookId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const dto = new CreateWebhookRequestDto();
|
||||
dto.name = 'TestWebhookForDelete';
|
||||
dto.url = 'https://example.com/webhook';
|
||||
dto.events = [
|
||||
{
|
||||
type: EventTypeEnum.FEEDBACK_CREATION,
|
||||
status: EventStatusEnum.ACTIVE,
|
||||
channelIds: [],
|
||||
},
|
||||
];
|
||||
dto.status = WebhookStatusEnum.ACTIVE;
|
||||
|
||||
const response = await request(app.getHttpServer() as Server)
|
||||
.post(`/admin/projects/${project.id}/webhooks`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.send(dto);
|
||||
|
||||
webhookId = (response.body as { id: number }).id;
|
||||
});
|
||||
|
||||
it('should delete webhook', async () => {
|
||||
await request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
return request(app.getHttpServer() as Server)
|
||||
.get(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it('should return 404 when deleting non-existent webhook', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/webhooks/999`)
|
||||
.set('Authorization', `Bearer ${accessToken}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 401 when unauthorized', async () => {
|
||||
return request(app.getHttpServer() as Server)
|
||||
.delete(`/admin/projects/${project.id}/webhooks/${webhookId}`)
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const delay = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
await delay(500);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
module.exports = {
|
||||
displayName: 'api',
|
||||
rootDir: './src',
|
||||
testRegex: '.*\\.spec\\.ts$',
|
||||
collectCoverageFrom: ['**/*.(t|j)s'],
|
||||
testEnvironment: 'node',
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': ['<rootDir>/$1'],
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': ['@swc-node/jest'],
|
||||
},
|
||||
transformIgnorePatterns: ['node_modules/(?!@faker-js|uuid)'],
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
coverageDirectory: '../coverage',
|
||||
clearMocks: true,
|
||||
resetMocks: true,
|
||||
setupFilesAfterEnv: ['<rootDir>/../jest.setup.js'],
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
jest.mock('typeorm-transactional', () => ({
|
||||
Transactional: () => () => ({}),
|
||||
initializeTransactionalContext: () => {},
|
||||
addTransactionalDataSource: (res) => res,
|
||||
}));
|
||||
jest.mock('nestjs-typeorm-paginate', () => ({
|
||||
paginate: (_, option) => {
|
||||
return {
|
||||
meta: {
|
||||
itemCount: 1,
|
||||
totalItems: (option.page - 1) * option.limit + 1,
|
||||
pageCount: option.page,
|
||||
currentPage: option.page,
|
||||
},
|
||||
items: [],
|
||||
};
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"builder": "swc",
|
||||
"deleteOutDir": true,
|
||||
"assets": ["**/*.hbs"],
|
||||
"watchAssets": true,
|
||||
"tsConfigPath": "tsconfig.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"clean": "git clean -xdf dist .turbo node_modules .cache",
|
||||
"dev": "nest start --watch",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore --ignore-path .prettierignore",
|
||||
"format:fix": "prettier --write --list-different \"./src/**/*.{js,cjs,mjs,ts,tsx,md,json}\"",
|
||||
"lint": "eslint",
|
||||
"migration:generate": "npm run typeorm -- migration:generate src/configs/modules/typeorm-config/migrations/$npm_config_name",
|
||||
"migration:revert": "npm run typeorm -- migration:revert",
|
||||
"migration:run": "npm run typeorm -- migration:run",
|
||||
"start": "nest start",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"test": "jest --detectOpenHandles --forceExit",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand --detectOpenHandles",
|
||||
"test:integration": "jest --config ./integration-test/jest-integration.json --runInBand --detectOpenHandles",
|
||||
"test:watch": "jest --watch --detectOpenHandles",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typeorm": "ts-node --project ./tsconfig.json -r tsconfig-paths/register ../../node_modules/typeorm/cli -d src/configs/modules/typeorm-config/typeorm-config.datasource.ts"
|
||||
},
|
||||
"prettier": "@ufb/prettier-config",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1015.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
||||
"@fastify/multipart": "^9.4.0",
|
||||
"@fastify/static": "^9.0.0",
|
||||
"@nestjs-modules/mailer": "^2.0.2",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.1.17",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.17",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/passport": "^11.0.5",
|
||||
"@nestjs/platform-express": "^11.1.17",
|
||||
"@nestjs/platform-fastify": "^11.1.17",
|
||||
"@nestjs/schedule": "^6.0.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@nestjs/terminus": "^11.1.1",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@opensearch-project/opensearch": "^3.5.1",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.213.0",
|
||||
"@opentelemetry/resources": "^2.6.0",
|
||||
"@opentelemetry/sdk-logs": "^0.213.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.40.0",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/passport-local": "^1.0.38",
|
||||
"@ufb/shared": "workspace:*",
|
||||
"@willsoto/nestjs-prometheus": "^6.0.2",
|
||||
"axios": "^1.13.6",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"cron": "^4.3.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"fast-csv": "^5.0.5",
|
||||
"fastify": "^5.8.4",
|
||||
"joi": "^18.1.1",
|
||||
"luxon": "^3.7.2",
|
||||
"magic-bytes.js": "^1.13.0",
|
||||
"mysql2": "^3.20.0",
|
||||
"nestjs-cls": "^6.2.0",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"nestjs-typeorm-paginate": "^4.1.0",
|
||||
"nodemailer": "^8.0.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-opentelemetry-transport": "^3.0.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prom-client": "^15.1.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"typeorm": "^0.3.28",
|
||||
"typeorm-naming-strategies": "^4.1.0",
|
||||
"typeorm-transactional": "^0.5.0",
|
||||
"uuid": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.17",
|
||||
"@swc-node/jest": "^1.9.1",
|
||||
"@swc/cli": "0.8.0",
|
||||
"@swc/core": "1.13.5",
|
||||
"@swc/helpers": "^0.5.19",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/luxon": "^3.7.1",
|
||||
"@types/node": "24.12.0",
|
||||
"@types/nodemailer": "^7.0.11",
|
||||
"@types/passport-jwt": "*",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^8.46.0",
|
||||
"@ufb/eslint-config": "workspace:*",
|
||||
"@ufb/prettier-config": "workspace:*",
|
||||
"@ufb/tsconfig": "workspace:*",
|
||||
"eslint": "catalog:",
|
||||
"jest": "^30.3.0",
|
||||
"mockdate": "^3.0.5",
|
||||
"prettier": "catalog:",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-loader": "^9.5.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
|
||||
import { Request } from 'express';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import pino from 'pino';
|
||||
|
||||
import { appConfig, appConfigSchema } from './configs/app.config';
|
||||
import { jwtConfig, jwtConfigSchema } from './configs/jwt.config';
|
||||
import {
|
||||
MailerConfigModule,
|
||||
OpensearchConfigModule,
|
||||
TypeOrmConfigModule,
|
||||
} from './configs/modules';
|
||||
import { mysqlConfig, mysqlConfigSchema } from './configs/mysql.config';
|
||||
import {
|
||||
opensearchConfig,
|
||||
opensearchConfigSchema,
|
||||
} from './configs/opensearch.config';
|
||||
import { createOtelLogTransport } from './configs/otel-log.config';
|
||||
import { smtpConfig, smtpConfigSchema } from './configs/smtp.config';
|
||||
import { AuthModule } from './domains/admin/auth/auth.module';
|
||||
import { ChannelModule } from './domains/admin/channel/channel/channel.module';
|
||||
import { FieldModule } from './domains/admin/channel/field/field.module';
|
||||
import { OptionModule } from './domains/admin/channel/option/option.module';
|
||||
import { FeedbackModule } from './domains/admin/feedback/feedback.module';
|
||||
import { HistoryModule } from './domains/admin/history/history.module';
|
||||
import { AIModule } from './domains/admin/project/ai/ai.module';
|
||||
import { ApiKeyModule } from './domains/admin/project/api-key/api-key.module';
|
||||
import { CategoryModule } from './domains/admin/project/category/category.module';
|
||||
import { IssueTrackerModule } from './domains/admin/project/issue-tracker/issue-tracker.module';
|
||||
import { IssueModule } from './domains/admin/project/issue/issue.module';
|
||||
import { MemberModule } from './domains/admin/project/member/member.module';
|
||||
import { ProjectModule } from './domains/admin/project/project/project.module';
|
||||
import { RoleModule } from './domains/admin/project/role/role.module';
|
||||
import { WebhookModule } from './domains/admin/project/webhook/webhook.module';
|
||||
import { FeedbackIssueStatisticsModule } from './domains/admin/statistics/feedback-issue/feedback-issue-statistics.module';
|
||||
import { FeedbackStatisticsModule } from './domains/admin/statistics/feedback/feedback-statistics.module';
|
||||
import { IssueStatisticsModule } from './domains/admin/statistics/issue/issue-statistics.module';
|
||||
import { TenantModule } from './domains/admin/tenant/tenant.module';
|
||||
import { UserModule } from './domains/admin/user/user.module';
|
||||
import { APIModule } from './domains/api/api.module';
|
||||
import { HealthModule } from './domains/operation/health/health.module';
|
||||
import { MigrationModule } from './domains/operation/migration/migration.module';
|
||||
import { SchedulerLockModule } from './domains/operation/scheduler-lock/scheduler-lock.module';
|
||||
|
||||
export const domainModules = [
|
||||
AuthModule,
|
||||
ChannelModule,
|
||||
FieldModule,
|
||||
OptionModule,
|
||||
FeedbackModule,
|
||||
CategoryModule,
|
||||
HealthModule,
|
||||
MigrationModule,
|
||||
ApiKeyModule,
|
||||
IssueTrackerModule,
|
||||
IssueModule,
|
||||
ProjectModule,
|
||||
RoleModule,
|
||||
TenantModule,
|
||||
UserModule,
|
||||
MemberModule,
|
||||
HistoryModule,
|
||||
WebhookModule,
|
||||
FeedbackStatisticsModule,
|
||||
IssueStatisticsModule,
|
||||
FeedbackIssueStatisticsModule,
|
||||
APIModule,
|
||||
SchedulerLockModule,
|
||||
AIModule,
|
||||
] as (typeof AuthModule)[];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmConfigModule,
|
||||
OpensearchConfigModule,
|
||||
MailerConfigModule,
|
||||
PrometheusModule.register(),
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, opensearchConfig, smtpConfig, jwtConfig, mysqlConfig],
|
||||
validationSchema: appConfigSchema
|
||||
.concat(jwtConfigSchema)
|
||||
.concat(mysqlConfigSchema)
|
||||
.concat(smtpConfigSchema)
|
||||
.concat(opensearchConfigSchema),
|
||||
validationOptions: { abortEarly: true },
|
||||
}),
|
||||
LoggerModule.forRootAsync({
|
||||
useFactory: () => {
|
||||
const transport: pino.TransportMultiOptions = {
|
||||
targets: [
|
||||
{ target: 'pino-pretty', options: { singleLine: true } },
|
||||
createOtelLogTransport(),
|
||||
],
|
||||
};
|
||||
return {
|
||||
pinoHttp: {
|
||||
transport,
|
||||
autoLogging: {
|
||||
ignore: (req: Request) => req.originalUrl === '/api/health',
|
||||
},
|
||||
customLogLevel: (req, res, err) => {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return 'silent';
|
||||
}
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
return 'silent';
|
||||
}
|
||||
if (res.statusCode >= 400 && res.statusCode < 500) {
|
||||
return 'warn';
|
||||
} else if (res.statusCode >= 500) {
|
||||
return 'error';
|
||||
} else if (err != null) {
|
||||
return 'error';
|
||||
}
|
||||
return 'info';
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
ClsModule.forRoot({
|
||||
global: true,
|
||||
middleware: { mount: true },
|
||||
}),
|
||||
ScheduleModule.forRoot(),
|
||||
EventEmitterModule.forRoot(),
|
||||
...domainModules,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { Type } from '@nestjs/common';
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
|
||||
|
||||
import { PaginationResponseDto } from '../dtos/pagination-response.dto';
|
||||
|
||||
export const ApiOkResponsePagination = <Dto extends Type<unknown>>(dto: Dto) =>
|
||||
applyDecorators(
|
||||
ApiExtraModels(PaginationResponseDto, dto),
|
||||
ApiOkResponse({
|
||||
schema: {
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(PaginationResponseDto) },
|
||||
{
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: { $ref: getSchemaPath(dto) },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
import DtoValidator from './dto-validator';
|
||||
|
||||
class Dto {
|
||||
@IsString()
|
||||
str: string;
|
||||
}
|
||||
|
||||
class TestClass {
|
||||
@DtoValidator()
|
||||
noParam() {
|
||||
return;
|
||||
}
|
||||
|
||||
@DtoValidator()
|
||||
dtoParam(_dto: Dto) {
|
||||
return;
|
||||
}
|
||||
|
||||
@DtoValidator()
|
||||
dtosParam(_dtos: Dto[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
@DtoValidator()
|
||||
compositionParam(_a: any, _dtos: Dto) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
describe('dto validator', () => {
|
||||
let instance: TestClass;
|
||||
beforeEach(() => {
|
||||
instance = new TestClass();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
instance.noParam();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
const dto = new Dto();
|
||||
dto.str = 'test';
|
||||
instance.dtoParam(dto);
|
||||
const dto2 = new Dto();
|
||||
void expect(instance.dtoParam(dto2)).rejects.toThrow();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
const dto = new Dto();
|
||||
dto.str = 'test';
|
||||
instance.dtosParam([dto]);
|
||||
const dto2 = new Dto();
|
||||
void expect(instance.dtosParam([dto2])).rejects.toThrow();
|
||||
});
|
||||
it('method call with no params', () => {
|
||||
const dto = new Dto();
|
||||
dto.str = '123';
|
||||
instance.compositionParam([1], dto);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { InternalServerErrorException } from '@nestjs/common';
|
||||
import type { ValidationError } from 'class-validator';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
type Method = (...args: object[]) => any;
|
||||
|
||||
const DtoValidator =
|
||||
() =>
|
||||
(
|
||||
target: unknown,
|
||||
propName: string,
|
||||
descriptor: TypedPropertyDescriptor<any>,
|
||||
) => {
|
||||
const methodRef = descriptor.value as Method;
|
||||
|
||||
descriptor.value = async function (...args: object[]): Promise<any> {
|
||||
for (const arg of args) {
|
||||
let errors: ValidationError[] = [];
|
||||
|
||||
if (!Array.isArray(arg) && typeof arg === 'object') {
|
||||
errors = await validate(arg);
|
||||
} else if (
|
||||
Array.isArray(arg) &&
|
||||
arg.length > 0 &&
|
||||
typeof arg[0] === 'object'
|
||||
) {
|
||||
errors = (
|
||||
await Promise.all(
|
||||
arg.map(async (item: object) => await validate(item)),
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new InternalServerErrorException(errors);
|
||||
}
|
||||
}
|
||||
return (await methodRef.call(this, ...args)) as object;
|
||||
};
|
||||
return descriptor;
|
||||
};
|
||||
export default DtoValidator;
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as DtoValidator } from './dto-validator';
|
||||
export { ApiOkResponsePagination } from './api-ok-response-pagination.decorator';
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { ValidationError, Validator } from 'class-validator';
|
||||
|
||||
import { IsPassword } from './is-password';
|
||||
|
||||
class IsPasswordTest {
|
||||
@IsPassword()
|
||||
password: any;
|
||||
}
|
||||
|
||||
describe('IsPassword decorator', () => {
|
||||
it('', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.string.sample(
|
||||
faker.number.int({ min: 8, max: 15 }),
|
||||
);
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors) => {
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
it('minLength', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.string.sample(
|
||||
faker.number.int({ min: 0, max: 7 }),
|
||||
);
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||
expect(errors.length).toEqual(1);
|
||||
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('minLength');
|
||||
});
|
||||
});
|
||||
|
||||
it('isString', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.number.int({ min: 10000000, max: 99999999 });
|
||||
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||
expect(errors.length).toEqual(1);
|
||||
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
|
||||
});
|
||||
});
|
||||
|
||||
it('isString, minLength', () => {
|
||||
const instance = new IsPasswordTest();
|
||||
instance.password = faker.number.int({ min: 0, max: 9999999 });
|
||||
const validator = new Validator();
|
||||
void validator.validate(instance).then((errors: ValidationError[]) => {
|
||||
expect(errors.length).toEqual(1);
|
||||
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
|
||||
expect(Object.keys(errors[0].constraints ?? {})[1]).toEqual('minLength');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export function IsPassword() {
|
||||
return applyDecorators(IsString(), MinLength(8));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { PaginationDto } from './pagination.dto';
|
||||
export { PaginationRequestDto } from './pagination-request.dto';
|
||||
export { PaginationResponseDto } from './pagination-response.dto';
|
||||
export { TimeRange } from './time-range.dto';
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||
|
||||
import { toNumber } from '@/common/helper/cast.helper';
|
||||
|
||||
export class PaginationRequestDto {
|
||||
@Transform(({ value }: { value: string }) =>
|
||||
toNumber(value, { default: 10, min: 1 }),
|
||||
)
|
||||
@ApiProperty({ required: false, minimum: 1, default: 10, example: 10 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
limit: number;
|
||||
|
||||
@Transform(({ value }: { value: string }) =>
|
||||
toNumber(value, { default: 1, min: 1 }),
|
||||
)
|
||||
@ApiProperty({ required: false, minimum: 1, default: 1, example: 1 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
page: number;
|
||||
|
||||
constructor(limit = 10, page = 1) {
|
||||
this.limit = limit;
|
||||
this.page = page;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Expose, Type } from 'class-transformer';
|
||||
import type { IPaginationMeta, Pagination } from 'nestjs-typeorm-paginate';
|
||||
|
||||
class PaginationMetaDto implements IPaginationMeta {
|
||||
@ApiProperty({ example: 10 })
|
||||
@Expose()
|
||||
itemCount: number;
|
||||
|
||||
@ApiProperty({ example: 100 })
|
||||
@Expose()
|
||||
totalItems?: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@Expose()
|
||||
itemsPerPage: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
@Expose()
|
||||
totalPages?: number;
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
@Expose()
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
export abstract class PaginationResponseDto<T> implements Pagination<T> {
|
||||
@ApiProperty()
|
||||
@Expose()
|
||||
@Type(() => PaginationMetaDto)
|
||||
meta: PaginationMetaDto;
|
||||
|
||||
abstract items: T[];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class PaginationDto {
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class TimeRange {
|
||||
@ApiProperty({ name: 'gte (UTC)' })
|
||||
gte: string;
|
||||
@ApiProperty({ name: 'lt (UTC)' })
|
||||
lt: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DateTime } from 'luxon';
|
||||
import {
|
||||
BeforeInsert,
|
||||
BeforeUpdate,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
export abstract class CommonEntity {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn()
|
||||
deletedAt: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
beforeInsertHook() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!this.createdAt) {
|
||||
this.createdAt = DateTime.utc().toJSDate();
|
||||
}
|
||||
this.updatedAt = DateTime.utc().toJSDate();
|
||||
}
|
||||
|
||||
@BeforeUpdate()
|
||||
beforeUpdateHook() {
|
||||
this.updatedAt = DateTime.utc().toJSDate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { CommonEntity } from './common.entity';
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export enum AIPromptStatusEnum {
|
||||
success = 'success',
|
||||
error = 'error',
|
||||
loading = 'loading',
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export enum AIProvidersEnum {
|
||||
OPEN_AI = 'OPEN_AI',
|
||||
GEMINI = 'GEMINI',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum EventStatusEnum {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum EventTypeEnum {
|
||||
FEEDBACK_CREATION = 'FEEDBACK_CREATION',
|
||||
ISSUE_CREATION = 'ISSUE_CREATION',
|
||||
ISSUE_STATUS_CHANGE = 'ISSUE_STATUS_CHANGE',
|
||||
ISSUE_ADDITION = 'ISSUE_ADDITION',
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum FieldFormatEnum {
|
||||
text = 'text',
|
||||
keyword = 'keyword',
|
||||
number = 'number',
|
||||
select = 'select',
|
||||
multiSelect = 'multiSelect',
|
||||
date = 'date',
|
||||
images = 'images',
|
||||
aiField = 'aiField',
|
||||
}
|
||||
|
||||
export function isSelectFieldFormat(type: FieldFormatEnum) {
|
||||
return [FieldFormatEnum.select, FieldFormatEnum.multiSelect].includes(type);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum FieldPropertyEnum {
|
||||
READ_ONLY = 'READ_ONLY',
|
||||
EDITABLE = 'EDITABLE',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum FieldStatusEnum {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { FieldFormatEnum, isSelectFieldFormat } from './field-format.enum';
|
||||
export { FieldPropertyEnum } from './field-property.enum';
|
||||
export { FieldStatusEnum } from './field-status.enum';
|
||||
export { IssueStatusEnum } from './issue-status.enum';
|
||||
export { SortMethodEnum } from './sort-method.enum';
|
||||
export { EventTypeEnum } from './event-type.enum';
|
||||
export { EventStatusEnum } from './event-status.enum';
|
||||
export { WebhookStatusEnum } from './webhook-status.enum';
|
||||
export { QueryV2ConditionsEnum } from './query-v2-conditions.enum';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum IssueStatusEnum {
|
||||
INIT = 'INIT',
|
||||
ON_REVIEW = 'ON_REVIEW',
|
||||
IN_PROGRESS = 'IN_PROGRESS',
|
||||
RESOLVED = 'RESOLVED',
|
||||
PENDING = 'PENDING',
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum QueryV2ConditionsEnum {
|
||||
CONTAINS = 'CONTAINS',
|
||||
IS = 'IS',
|
||||
BETWEEN = 'BETWEEN',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum SortMethodEnum {
|
||||
ASC = 'ASC',
|
||||
DESC = 'DESC',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export enum WebhookStatusEnum {
|
||||
ACTIVE = 'ACTIVE',
|
||||
INACTIVE = 'INACTIVE',
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ArgumentsHost } from '@nestjs/common';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { HttpExceptionFilter } from './http-exception.filter';
|
||||
|
||||
describe('HttpExceptionFilter', () => {
|
||||
let filter: HttpExceptionFilter;
|
||||
let mockRequest: FastifyRequest;
|
||||
let mockResponse: FastifyReply;
|
||||
let mockArgumentsHost: ArgumentsHost;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [HttpExceptionFilter],
|
||||
}).compile();
|
||||
|
||||
filter = module.get<HttpExceptionFilter>(HttpExceptionFilter);
|
||||
|
||||
// Mock FastifyRequest
|
||||
mockRequest = {
|
||||
url: '/test-endpoint',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
query: {},
|
||||
params: {},
|
||||
body: {},
|
||||
} as FastifyRequest;
|
||||
|
||||
// Mock FastifyReply
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as unknown as FastifyReply;
|
||||
|
||||
// Mock ArgumentsHost
|
||||
mockArgumentsHost = {
|
||||
switchToHttp: jest.fn().mockReturnValue({
|
||||
getRequest: jest.fn().mockReturnValue(mockRequest),
|
||||
getResponse: jest.fn().mockReturnValue(mockResponse),
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('catch', () => {
|
||||
it('should handle string exception response', () => {
|
||||
const exception = new HttpException(
|
||||
'Test error message',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
response: 'Test error message',
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle object exception response', () => {
|
||||
const exceptionResponse = {
|
||||
message: 'Validation failed',
|
||||
error: 'Bad Request',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
};
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
message: 'Validation failed',
|
||||
error: 'Bad Request',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different HTTP status codes', () => {
|
||||
const statusCodes = [
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
HttpStatus.FORBIDDEN,
|
||||
HttpStatus.NOT_FOUND,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
];
|
||||
|
||||
statusCodes.forEach((statusCode) => {
|
||||
const exception = new HttpException('Test error', statusCode);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(statusCode);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
response: 'Test error',
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complex object exception response', () => {
|
||||
const exceptionResponse = {
|
||||
message: ['Email is required', 'Password is too short'],
|
||||
error: 'Validation Error',
|
||||
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
details: {
|
||||
field: 'email',
|
||||
value: '',
|
||||
},
|
||||
};
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(
|
||||
HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
message: ['Email is required', 'Password is too short'],
|
||||
error: 'Validation Error',
|
||||
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
path: '/test-endpoint',
|
||||
details: {
|
||||
field: 'email',
|
||||
value: '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle empty string exception response', () => {
|
||||
const exception = new HttpException('', HttpStatus.NO_CONTENT);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
response: '',
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null exception response', () => {
|
||||
const exception = new HttpException(null as any, HttpStatus.NO_CONTENT);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
statusCode: HttpStatus.NO_CONTENT,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined exception response', () => {
|
||||
const exception = new HttpException(
|
||||
undefined as any,
|
||||
HttpStatus.NO_CONTENT,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
statusCode: HttpStatus.NO_CONTENT,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle different request URLs', () => {
|
||||
const urls = [
|
||||
'/api/users',
|
||||
'/api/projects/123',
|
||||
'/api/auth/login',
|
||||
'/api/feedback?page=1&limit=10',
|
||||
];
|
||||
|
||||
urls.forEach((url) => {
|
||||
Object.assign(mockRequest, { url });
|
||||
const exception = new HttpException(
|
||||
'Test error',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
response: 'Test error',
|
||||
path: url,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle nested object exception response', () => {
|
||||
const exceptionResponse = {
|
||||
message: 'Complex error',
|
||||
error: 'Internal Server Error',
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
nested: {
|
||||
level1: {
|
||||
level2: {
|
||||
value: 'deep nested value',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
message: 'Complex error',
|
||||
error: 'Internal Server Error',
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
path: '/test-endpoint',
|
||||
nested: {
|
||||
level1: {
|
||||
level2: {
|
||||
value: 'deep nested value',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array exception response', () => {
|
||||
const exceptionResponse = ['Error 1', 'Error 2', 'Error 3'];
|
||||
const exception = new HttpException(
|
||||
exceptionResponse,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
0: 'Error 1',
|
||||
1: 'Error 2',
|
||||
2: 'Error 3',
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle boolean exception response', () => {
|
||||
const exception = new HttpException(true as any, HttpStatus.OK);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
statusCode: HttpStatus.OK,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle number exception response', () => {
|
||||
const exception = new HttpException(42 as any, HttpStatus.OK);
|
||||
|
||||
filter.catch(exception, mockArgumentsHost);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith({
|
||||
statusCode: HttpStatus.OK,
|
||||
path: '/test-endpoint',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
|
||||
import { Catch, HttpException, Logger } from '@nestjs/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<FastifyReply>();
|
||||
const request = ctx.getRequest<FastifyRequest>();
|
||||
|
||||
const statusCode = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
this.logger.error({ statusCode, exceptionResponse });
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
void response.status(statusCode).send({
|
||||
response: exceptionResponse,
|
||||
path: request.url,
|
||||
});
|
||||
} else {
|
||||
void response.status(statusCode).send({
|
||||
...exceptionResponse,
|
||||
statusCode,
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { HttpExceptionFilter } from './http-exception.filter';
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
interface ToNumberOptions {
|
||||
default?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export function toLowerCase(value: string): string {
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
export function trim(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function toDate(value: string): Date {
|
||||
return new Date(value);
|
||||
}
|
||||
|
||||
export function toBoolean(value: string): boolean {
|
||||
value = value.toLowerCase();
|
||||
|
||||
return value === 'true' || value === '1' ? true : false;
|
||||
}
|
||||
|
||||
export function toNumber(value: string, opts: ToNumberOptions = {}): number {
|
||||
let newValue: number = Number.parseInt(value || String(opts.default), 10);
|
||||
|
||||
if (Number.isNaN(newValue)) {
|
||||
newValue = opts.default ?? 0;
|
||||
}
|
||||
|
||||
if (opts.min) {
|
||||
if (newValue < opts.min) {
|
||||
newValue = opts.min;
|
||||
}
|
||||
|
||||
if (opts.max && newValue > opts.max) {
|
||||
newValue = opts.max;
|
||||
}
|
||||
}
|
||||
|
||||
return newValue;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { paginate } from 'nestjs-typeorm-paginate';
|
||||
import type {
|
||||
IPaginationMeta,
|
||||
IPaginationOptions,
|
||||
} from 'nestjs-typeorm-paginate';
|
||||
import type { FindManyOptions, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
export async function paginateHelper(
|
||||
queryBuilder: SelectQueryBuilder<any>,
|
||||
findOptions: FindManyOptions<any>,
|
||||
options: IPaginationOptions,
|
||||
) {
|
||||
const totalItems = await queryBuilder
|
||||
.clone()
|
||||
.setFindOptions(findOptions)
|
||||
.getCount();
|
||||
return await paginate(queryBuilder.setFindOptions(findOptions), {
|
||||
...options,
|
||||
countQueries: false,
|
||||
metaTransformer: (meta: IPaginationMeta): IPaginationMeta => {
|
||||
return {
|
||||
...meta,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / meta.itemsPerPage),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class CreateDataDto {
|
||||
id?: string;
|
||||
index: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class CreateIndexDto {
|
||||
index: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class DeleteBulkDataDto {
|
||||
ids: number[];
|
||||
index: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PaginationDto } from '@/common/dtos';
|
||||
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
|
||||
|
||||
export class GetDataDto extends PaginationDto {
|
||||
index: string;
|
||||
query: OsQueryDto;
|
||||
sort: string[];
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { CreateIndexDto } from './create-index.dto';
|
||||
export { PutMappingsDto } from './put-mappings.dto';
|
||||
export { CreateDataDto } from './create-data.dto';
|
||||
export { GetDataDto } from './get-data.dto';
|
||||
export { UpdateDataDto } from './update-data.dto';
|
||||
export { DeleteBulkDataDto } from './delete-bulk-data.dto';
|
||||
export { ScrollDto } from './scroll.dto';
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import type { Property } from '@opensearch-project/opensearch/api/_types/_common.mapping';
|
||||
|
||||
export class PutMappingsDto {
|
||||
index: string;
|
||||
mappings: Record<string, Property>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
|
||||
|
||||
export class ScrollDto {
|
||||
index: string;
|
||||
query: OsQueryDto;
|
||||
sort: string[];
|
||||
size: number;
|
||||
scrollId: string | null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export class UpdateDataDto {
|
||||
id: string;
|
||||
index: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { OpensearchRepository } from './opensearch.repository';
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ErrorCode } from '@ufb/shared';
|
||||
|
||||
export class LargeWindowException extends BadRequestException {
|
||||
constructor(message: string) {
|
||||
super({
|
||||
code: ErrorCode.Opensearch.LargeWindow,
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { faker } from '@faker-js/faker';
|
||||
import {
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { Client } from '@opensearch-project/opensearch';
|
||||
import type { TextProperty } from '@opensearch-project/opensearch/api/_types/_common.mapping';
|
||||
|
||||
import { getMockProvider } from '@/test-utils/util-functions';
|
||||
import { CreateDataDto, PutMappingsDto } from './dtos';
|
||||
import { OpensearchRepository } from './opensearch.repository';
|
||||
|
||||
const MockClient = {
|
||||
indices: {
|
||||
create: jest.fn(),
|
||||
putAlias: jest.fn(),
|
||||
exists: jest.fn(),
|
||||
putMapping: jest.fn(),
|
||||
getMapping: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
index: jest.fn(),
|
||||
search: jest.fn(),
|
||||
scroll: jest.fn(),
|
||||
update: jest.fn(),
|
||||
deleteByQuery: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const OpensearchRepositoryProviders = [
|
||||
OpensearchRepository,
|
||||
getMockProvider('OPENSEARCH_CLIENT', MockClient),
|
||||
];
|
||||
|
||||
const COMPLICATE_JSON = {
|
||||
KEY1: 'VALUE1',
|
||||
KEY2: 'VALUE2',
|
||||
};
|
||||
|
||||
const MAPPING_JSON = {
|
||||
KEY1: {
|
||||
type: 'text',
|
||||
} as TextProperty,
|
||||
};
|
||||
|
||||
describe('Opensearch Repository Test suite', () => {
|
||||
let osRepo: OpensearchRepository;
|
||||
let osClient: Client;
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
providers: OpensearchRepositoryProviders,
|
||||
}).compile();
|
||||
osRepo = module.get(OpensearchRepository);
|
||||
osClient = module.get('OPENSEARCH_CLIENT');
|
||||
});
|
||||
|
||||
describe('create index', () => {
|
||||
it('positive case', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const indexName = 'channel_' + index;
|
||||
jest.spyOn(osClient.indices, 'create');
|
||||
jest.spyOn(osClient.indices, 'putAlias');
|
||||
|
||||
await osRepo.createIndex({ index });
|
||||
|
||||
expect(osClient.indices.create).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.create).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
body: {
|
||||
settings: {
|
||||
index: { max_ngram_diff: 1 },
|
||||
analysis: {
|
||||
analyzer: {
|
||||
ngram_analyzer: {
|
||||
filter: ['lowercase', 'asciifolding', 'cjk_width'],
|
||||
tokenizer: 'ngram_tokenizer',
|
||||
type: 'custom',
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
ngram_tokenizer: {
|
||||
type: 'ngram',
|
||||
min_gram: 1,
|
||||
max_gram: 2,
|
||||
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(osClient.indices.putAlias).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putAlias).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
name: index,
|
||||
});
|
||||
});
|
||||
|
||||
it('creating index handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const error = new Error('Index creation failed');
|
||||
|
||||
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.createIndex({ index })).rejects.toThrow(
|
||||
'Index creation failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('creating index handles OpenSearch specific errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const error = {
|
||||
meta: {
|
||||
body: {
|
||||
error: {
|
||||
type: 'resource_already_exists_exception',
|
||||
reason: 'index already exists',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.createIndex({ index })).rejects.toEqual(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('putMappings', () => {
|
||||
it('putting mappings succeeds with an existent index', async () => {
|
||||
const dto = new PutMappingsDto();
|
||||
dto.index = faker.number.int().toString();
|
||||
dto.mappings = MAPPING_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
jest.spyOn(osClient.indices, 'putMapping');
|
||||
|
||||
await osRepo.putMappings(dto);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putMapping).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putMapping).toHaveBeenCalledWith({
|
||||
index: dto.index,
|
||||
body: { properties: dto.mappings },
|
||||
});
|
||||
});
|
||||
it('putting mappings fails with a nonexistent index', async () => {
|
||||
const dto = new PutMappingsDto();
|
||||
dto.index = faker.number.int().toString();
|
||||
dto.mappings = MAPPING_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 404 } as never);
|
||||
jest.spyOn(osClient.indices, 'putMapping');
|
||||
|
||||
await expect(osRepo.putMappings(dto)).rejects.toThrow(
|
||||
new NotFoundException('index is not found'),
|
||||
);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.putMapping).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('putting mappings handles OpenSearch errors', async () => {
|
||||
const dto = new PutMappingsDto();
|
||||
dto.index = faker.number.int().toString();
|
||||
dto.mappings = MAPPING_JSON;
|
||||
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
|
||||
const error = {
|
||||
meta: {
|
||||
body: {
|
||||
error: {
|
||||
type: 'illegal_argument_exception',
|
||||
reason: 'mapping update failed',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(osClient.indices, 'putMapping')
|
||||
.mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.putMappings(dto)).rejects.toEqual(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createData', () => {
|
||||
it('creating data succeeds with valid inputs', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const dto = new CreateDataDto();
|
||||
dto.id = id;
|
||||
dto.index = index;
|
||||
dto.data = COMPLICATE_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||
body: {
|
||||
['channel_' + index]: {
|
||||
mappings: {
|
||||
properties: dto.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||
body: {
|
||||
_id: dto.id,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const response = await osRepo.createData(dto);
|
||||
|
||||
expect(response.id).toEqual(dto.id);
|
||||
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.index).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.index).toHaveBeenCalledWith({
|
||||
id: dto.id,
|
||||
index: 'channel_' + index,
|
||||
body: dto.data,
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
it('creating data fails with an invalid index', async () => {
|
||||
const invalidIndex = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const dto = new CreateDataDto();
|
||||
dto.id = id;
|
||||
dto.index = invalidIndex;
|
||||
dto.data = COMPLICATE_JSON;
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ body: false } as never);
|
||||
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||
body: {
|
||||
['channel_' + faker.number.int().toString()]: {
|
||||
mappings: {
|
||||
properties: dto.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||
body: {
|
||||
_id: dto.id,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(osRepo.createData(dto)).rejects.toThrow(
|
||||
new NotFoundException('index is not found'),
|
||||
);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.getMapping).not.toHaveBeenCalled();
|
||||
expect(osClient.index).not.toHaveBeenCalled();
|
||||
});
|
||||
it('creating data fails with invalid data', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const data = COMPLICATE_JSON;
|
||||
const dto = new CreateDataDto();
|
||||
dto.id = id;
|
||||
dto.index = index;
|
||||
dto.data = {
|
||||
...data,
|
||||
invalidKey: 'invalidValue',
|
||||
};
|
||||
jest
|
||||
.spyOn(osClient.indices, 'exists')
|
||||
.mockResolvedValue({ statusCode: 200 } as never);
|
||||
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
|
||||
body: {
|
||||
['channel_' + index]: {
|
||||
mappings: {
|
||||
properties: data,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
jest.spyOn(osClient, 'index').mockResolvedValue({
|
||||
body: {
|
||||
_id: dto.id,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(osRepo.createData(dto)).rejects.toThrow(
|
||||
new InternalServerErrorException('error!!!'),
|
||||
);
|
||||
|
||||
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
|
||||
expect(osClient.index).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getData', () => {
|
||||
it('getting data succeeds with valid inputs', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort = ['_id:desc'];
|
||||
const limit = 10;
|
||||
const page = 1;
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: [
|
||||
{ _source: { KEY1: 'VALUE1' } },
|
||||
{ _source: { KEY2: 'VALUE2' } },
|
||||
],
|
||||
total: 2,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getData({ index, query, sort, limit, page });
|
||||
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
from: 0,
|
||||
size: limit,
|
||||
sort,
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting data with empty sort adds default sort', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort: string[] = [];
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: [],
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.getData({ index, query, sort, page: 1, limit: 100 });
|
||||
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
from: 0,
|
||||
size: 100,
|
||||
sort: ['_id:desc'],
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting data handles large window exception', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
|
||||
const error = new Error('Result window is too large');
|
||||
error.name = 'OpenSearchClientError';
|
||||
|
||||
jest.spyOn(osClient, 'search').mockRejectedValue(error as never);
|
||||
|
||||
await expect(
|
||||
osRepo.getData({ index, query, sort: [], page: 1, limit: 100 }),
|
||||
).rejects.toThrow('Result window is too large');
|
||||
});
|
||||
|
||||
it('getting data handles total as object', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: [],
|
||||
total: { value: 100, relation: 'eq' },
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getData({
|
||||
index,
|
||||
query,
|
||||
sort: [],
|
||||
page: 1,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
expect(result.total).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scroll', () => {
|
||||
it('scrolling with scrollId succeeds', async () => {
|
||||
const scrollId = faker.string.alphanumeric(32);
|
||||
const mockData = [{ KEY1: 'VALUE1' }, { KEY2: 'VALUE2' }];
|
||||
|
||||
jest.spyOn(osClient, 'scroll').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: mockData.map((data) => ({ _source: data })),
|
||||
},
|
||||
_scroll_id: scrollId,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.scroll({
|
||||
scrollId,
|
||||
index: '',
|
||||
size: 10,
|
||||
query: { bool: { must: [{ term: { status: 'active' } }] } },
|
||||
sort: [],
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(mockData);
|
||||
expect(result.scrollId).toEqual(scrollId);
|
||||
expect(osClient.scroll).toHaveBeenCalledWith({
|
||||
scroll_id: scrollId,
|
||||
scroll: '1m',
|
||||
});
|
||||
});
|
||||
|
||||
it('scrolling without scrollId performs initial search', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort = ['_id:desc'];
|
||||
const size = 10;
|
||||
const mockData = [{ KEY1: 'VALUE1' }];
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: {
|
||||
hits: mockData.map((data) => ({ _source: data })),
|
||||
},
|
||||
_scroll_id: 'new_scroll_id',
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.scroll({
|
||||
index,
|
||||
query,
|
||||
sort,
|
||||
size,
|
||||
scrollId: null,
|
||||
});
|
||||
|
||||
expect(result.data).toEqual(mockData);
|
||||
expect(result.scrollId).toEqual('new_scroll_id');
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
size,
|
||||
sort,
|
||||
body: { query },
|
||||
scroll: '1m',
|
||||
});
|
||||
});
|
||||
|
||||
it('scrolling with empty sort adds default sort', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const sort: string[] = [];
|
||||
|
||||
jest.spyOn(osClient, 'search').mockResolvedValue({
|
||||
body: {
|
||||
hits: { hits: [] },
|
||||
_scroll_id: 'scroll_id',
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.scroll({ index, query, sort, size: 10, scrollId: null });
|
||||
|
||||
expect(osClient.search).toHaveBeenCalledWith({
|
||||
index,
|
||||
size: 10,
|
||||
sort: ['_id:desc'],
|
||||
body: { query },
|
||||
scroll: '1m',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateData', () => {
|
||||
it('updating data succeeds with valid inputs', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const updateData = { KEY1: 'UPDATED_VALUE' };
|
||||
|
||||
jest.spyOn(osClient, 'update').mockResolvedValue({
|
||||
body: {
|
||||
_id: id,
|
||||
result: 'updated',
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.updateData({ index, id, data: updateData });
|
||||
|
||||
expect(osClient.update).toHaveBeenCalledWith({
|
||||
index,
|
||||
id,
|
||||
body: {
|
||||
doc: updateData,
|
||||
},
|
||||
refresh: true,
|
||||
retry_on_conflict: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('updating data handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const id = faker.number.int().toString();
|
||||
const updateData = { KEY1: 'UPDATED_VALUE' };
|
||||
const error = new Error('Update failed');
|
||||
|
||||
jest.spyOn(osClient, 'update').mockRejectedValue(error as never);
|
||||
|
||||
await expect(
|
||||
osRepo.updateData({ index, id, data: updateData }),
|
||||
).rejects.toThrow('Update failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteBulkData', () => {
|
||||
it('deleting bulk data succeeds with valid ids', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const ids = [faker.number.int(), faker.number.int()];
|
||||
|
||||
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
|
||||
body: {
|
||||
deleted: ids.length,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteBulkData({ index, ids });
|
||||
|
||||
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query: { terms: { _id: ids } } },
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting bulk data with empty ids array', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const ids: number[] = [];
|
||||
|
||||
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
|
||||
body: {
|
||||
deleted: 0,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteBulkData({ index, ids });
|
||||
|
||||
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query: { terms: { _id: ids } } },
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteIndex', () => {
|
||||
it('deleting index succeeds with valid index', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const indexName = 'channel_' + index;
|
||||
|
||||
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
|
||||
body: {
|
||||
acknowledged: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteIndex(index);
|
||||
|
||||
expect(osClient.indices.delete).toHaveBeenCalledWith({
|
||||
index: indexName,
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting index handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const error = new Error('Delete failed');
|
||||
|
||||
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.deleteIndex(index)).rejects.toThrow('Delete failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotal', () => {
|
||||
it('getting total count succeeds with valid query', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
|
||||
jest.spyOn(osClient, 'count').mockResolvedValue({
|
||||
body: {
|
||||
count: 100,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getTotal(index, query);
|
||||
|
||||
expect(result).toBe(100);
|
||||
expect(osClient.count).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting total count with complex query', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = {
|
||||
bool: {
|
||||
must: [
|
||||
{ term: { status: 'active' } },
|
||||
{ range: { created_at: { gte: '2023-01-01' } } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(osClient, 'count').mockResolvedValue({
|
||||
body: {
|
||||
count: 50,
|
||||
},
|
||||
} as never);
|
||||
|
||||
const result = await osRepo.getTotal(index, query);
|
||||
|
||||
expect(result).toBe(50);
|
||||
expect(osClient.count).toHaveBeenCalledWith({
|
||||
index,
|
||||
body: { query },
|
||||
});
|
||||
});
|
||||
|
||||
it('getting total count handles errors', async () => {
|
||||
const index = faker.number.int().toString();
|
||||
const query = { bool: { must: [{ term: { status: 'active' } }] } };
|
||||
const error = new Error('Count failed');
|
||||
|
||||
jest.spyOn(osClient, 'count').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.getTotal(index, query)).rejects.toThrow(
|
||||
'Count failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAllIndexes', () => {
|
||||
it('deleting all indexes succeeds', async () => {
|
||||
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
|
||||
body: {
|
||||
acknowledged: true,
|
||||
},
|
||||
} as never);
|
||||
|
||||
await osRepo.deleteAllIndexes();
|
||||
|
||||
expect(osClient.indices.delete).toHaveBeenCalledWith({
|
||||
index: '_all',
|
||||
});
|
||||
});
|
||||
|
||||
it('deleting all indexes handles errors', async () => {
|
||||
const error = new Error('Delete all failed');
|
||||
|
||||
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
|
||||
|
||||
await expect(osRepo.deleteAllIndexes()).rejects.toThrow(
|
||||
'Delete all failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Client, errors } from '@opensearch-project/opensearch';
|
||||
import { Indices_PutMapping_Response } from '@opensearch-project/opensearch/api';
|
||||
|
||||
import type {
|
||||
CreateDataDto,
|
||||
CreateIndexDto,
|
||||
DeleteBulkDataDto,
|
||||
GetDataDto,
|
||||
PutMappingsDto,
|
||||
ScrollDto,
|
||||
UpdateDataDto,
|
||||
} from './dtos';
|
||||
import { LargeWindowException } from './large-window.exception';
|
||||
|
||||
@Injectable()
|
||||
export class OpensearchRepository {
|
||||
private logger = new Logger(OpensearchRepository.name);
|
||||
private opensearchClient: Client;
|
||||
constructor(@Inject('OPENSEARCH_CLIENT') opensearchClient: Client) {
|
||||
this.opensearchClient = opensearchClient;
|
||||
}
|
||||
|
||||
async createIndex({ index }: CreateIndexDto) {
|
||||
const indexName = 'channel_' + index;
|
||||
try {
|
||||
const response = await this.opensearchClient.indices.create({
|
||||
index: indexName,
|
||||
body: {
|
||||
settings: {
|
||||
index: { max_ngram_diff: 1 },
|
||||
analysis: {
|
||||
analyzer: {
|
||||
ngram_analyzer: {
|
||||
type: 'custom',
|
||||
filter: ['lowercase', 'asciifolding', 'cjk_width'],
|
||||
tokenizer: 'ngram_tokenizer',
|
||||
},
|
||||
},
|
||||
tokenizer: {
|
||||
ngram_tokenizer: {
|
||||
type: 'ngram',
|
||||
min_gram: 1,
|
||||
max_gram: 2,
|
||||
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (response) {
|
||||
this.logger.log(
|
||||
`Index created successfully: ${JSON.stringify(response.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.log(`Error creating index: ${error}`);
|
||||
if (error?.meta?.body) {
|
||||
this.logger.log(
|
||||
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await this.opensearchClient.indices.putAlias({
|
||||
index: indexName,
|
||||
name: index,
|
||||
});
|
||||
}
|
||||
|
||||
async putMappings({ index, mappings }: PutMappingsDto) {
|
||||
const { statusCode } = await this.opensearchClient.indices.exists({
|
||||
index,
|
||||
});
|
||||
if (statusCode !== 200) throw new NotFoundException('index is not found');
|
||||
|
||||
let response: Indices_PutMapping_Response;
|
||||
try {
|
||||
response = await this.opensearchClient.indices.putMapping({
|
||||
index,
|
||||
body: { properties: mappings },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.log(`Error put mapping: ${error}`);
|
||||
if (error?.meta?.body) {
|
||||
this.logger.log(
|
||||
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async createData({ id, index, data }: CreateDataDto) {
|
||||
const indexName = 'channel_' + index;
|
||||
const existence = await this.opensearchClient.indices.exists({
|
||||
index: indexName,
|
||||
});
|
||||
if (existence.statusCode !== 200)
|
||||
throw new NotFoundException('index is not found');
|
||||
|
||||
const response = await this.opensearchClient.indices.getMapping({
|
||||
index: indexName,
|
||||
});
|
||||
|
||||
const mappingKeys = Object.keys(
|
||||
response.body[indexName].mappings.properties as object,
|
||||
);
|
||||
const dataKeys = Object.keys(data);
|
||||
if (!dataKeys.every((v) => mappingKeys.includes(v))) {
|
||||
throw new InternalServerErrorException('error!!!');
|
||||
}
|
||||
|
||||
const { body } = await this.opensearchClient.index({
|
||||
id,
|
||||
index: indexName,
|
||||
body: data,
|
||||
refresh: true,
|
||||
});
|
||||
|
||||
return { id: body._id as unknown as number };
|
||||
}
|
||||
|
||||
async getData(dto: GetDataDto) {
|
||||
const { index, limit = 100, page = 1, query, sort } = dto;
|
||||
|
||||
if (sort.length === 0) {
|
||||
sort.push('_id:desc');
|
||||
}
|
||||
try {
|
||||
const { body } = await this.opensearchClient.search({
|
||||
index,
|
||||
from: (page - 1) * limit,
|
||||
size: limit,
|
||||
sort,
|
||||
body: { query },
|
||||
});
|
||||
|
||||
return {
|
||||
items: body.hits.hits.map((v) => ({
|
||||
...v._source,
|
||||
})) as Record<string, any>[],
|
||||
total:
|
||||
typeof body.hits.total === 'number' ?
|
||||
body.hits.total
|
||||
: (body.hits.total?.value ?? 0),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof errors.OpenSearchClientError) {
|
||||
if (error.message.includes('Result window is too large')) {
|
||||
throw new LargeWindowException(error.message);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async scroll(dto: ScrollDto) {
|
||||
const { index, size, scrollId, query, sort } = dto;
|
||||
|
||||
if (sort.length === 0) sort.push('_id:desc');
|
||||
|
||||
if (scrollId) {
|
||||
const { body } = await this.opensearchClient.scroll({
|
||||
scroll_id: scrollId,
|
||||
scroll: '1m',
|
||||
});
|
||||
return this.convertToScrollData(body);
|
||||
}
|
||||
|
||||
const { body } = await this.opensearchClient.search({
|
||||
index,
|
||||
size,
|
||||
sort,
|
||||
body: { query },
|
||||
scroll: '1m',
|
||||
});
|
||||
return this.convertToScrollData(body);
|
||||
}
|
||||
|
||||
private convertToScrollData(body) {
|
||||
return {
|
||||
data: body.hits.hits.map((v) => ({
|
||||
...v._source,
|
||||
})) as Record<string, any>[],
|
||||
scrollId: body._scroll_id,
|
||||
};
|
||||
}
|
||||
|
||||
async updateData({ id, index, data }: UpdateDataDto) {
|
||||
try {
|
||||
await this.opensearchClient.update({
|
||||
id,
|
||||
index,
|
||||
body: { doc: data },
|
||||
refresh: true,
|
||||
retry_on_conflict: 5,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Error updating data: ${error}`);
|
||||
if (error?.meta?.body) {
|
||||
this.logger.error(
|
||||
`OpenSearch error details: ${JSON.stringify(error.meta.body, null, 2)}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteBulkData({ ids, index }: DeleteBulkDataDto) {
|
||||
await this.opensearchClient.deleteByQuery({
|
||||
index,
|
||||
body: { query: { terms: { _id: ids } } },
|
||||
refresh: true,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteIndex(index: string) {
|
||||
await this.opensearchClient.indices.delete({ index: 'channel_' + index });
|
||||
}
|
||||
|
||||
async deleteAllIndexes() {
|
||||
await this.opensearchClient.indices.delete({ index: '_all' });
|
||||
}
|
||||
|
||||
async getTotal(index: string, query: object): Promise<number> {
|
||||
const { body } = await this.opensearchClient.count({
|
||||
index,
|
||||
body: { query },
|
||||
});
|
||||
|
||||
return body.count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ValidationArguments, ValidationOptions } from 'class-validator';
|
||||
import { registerDecorator } from 'class-validator';
|
||||
|
||||
export const ArrayDistinct = (
|
||||
property?: string,
|
||||
validationOptions?: ValidationOptions,
|
||||
) => {
|
||||
return (object: object, propertyName: string) => {
|
||||
registerDecorator({
|
||||
name: 'ArrayDistinct',
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
constraints: [property],
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(value: unknown): boolean {
|
||||
return Array.isArray(value) ?
|
||||
[...new Set(value)].length === value.length
|
||||
: false;
|
||||
},
|
||||
defaultMessage(args: ValidationArguments): string {
|
||||
return `must not contains duplicate entry for ${args.constraints[0]}`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { ArrayDistinct } from './array-distinct';
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { IsNotEmpty, validate } from 'class-validator';
|
||||
|
||||
import { TokenValidator } from './token-validator';
|
||||
|
||||
class TokenDto {
|
||||
@IsNotEmpty()
|
||||
@TokenValidator({ message: 'Invalid token format' })
|
||||
token: string;
|
||||
}
|
||||
|
||||
describe('TokenValidator', () => {
|
||||
it('should validate a correct token', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = 'validToken123456';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should invalidate a token with invalid characters', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = 'invalidToken$123';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
|
||||
});
|
||||
|
||||
it('should invalidate a token that is too short', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = 'short';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
|
||||
});
|
||||
|
||||
it('should invalidate an empty token', async () => {
|
||||
const dto = new TokenDto();
|
||||
dto.token = '';
|
||||
|
||||
const errors = await validate(dto);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ async: false })
|
||||
export class TokenValidatorConstraint implements ValidatorConstraintInterface {
|
||||
validate(token: string | null) {
|
||||
const regex = /^[a-zA-Z0-9._-]+$/;
|
||||
return (
|
||||
!token ||
|
||||
(typeof token === 'string' && regex.test(token) && token.length >= 16)
|
||||
);
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return 'Token must be at least 16 characters long and contain only alphanumeric characters, dots, hyphens, and underscores.';
|
||||
}
|
||||
}
|
||||
|
||||
export function TokenValidator(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
constraints: [],
|
||||
validator: TokenValidatorConstraint,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import Joi from 'joi';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const appConfigSchema = Joi.object({
|
||||
APP_PORT: Joi.number().default(4000),
|
||||
APP_ADDRESS: Joi.string().default('0.0.0.0'),
|
||||
ADMIN_WEB_URL: Joi.string().default('http://localhost:3000'),
|
||||
BASE_URL: Joi.string().optional(),
|
||||
AUTO_FEEDBACK_DELETION_ENABLED: Joi.boolean().default(false),
|
||||
AUTO_FEEDBACK_DELETION_PERIOD_DAYS: Joi.number().when(
|
||||
'AUTO_FEEDBACK_DELETION_ENABLED',
|
||||
{
|
||||
is: true,
|
||||
then: Joi.required(),
|
||||
otherwise: Joi.optional(),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
export const appConfig = registerAs('app', () => ({
|
||||
port: process.env.APP_PORT,
|
||||
address: process.env.APP_ADDRESS,
|
||||
adminWebUrl: process.env.ADMIN_WEB_URL,
|
||||
baseUrl: process.env.BASE_URL,
|
||||
enableAutoFeedbackDeletion:
|
||||
process.env.AUTO_FEEDBACK_DELETION_ENABLED === 'true',
|
||||
autoFeedbackDeletionPeriodDays:
|
||||
process.env.AUTO_FEEDBACK_DELETION_PERIOD_DAYS,
|
||||
serverId: uuidv4(),
|
||||
}));
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { registerAs } from '@nestjs/config';
|
||||
import Joi from 'joi';
|
||||
|
||||
export const jwtConfigSchema = Joi.object({
|
||||
JWT_SECRET: Joi.string().required(),
|
||||
ACCESS_TOKEN_EXPIRED_TIME: Joi.string().default('10m'),
|
||||
REFRESH_TOKEN_EXPIRED_TIME: Joi.string().default('1h'),
|
||||
});
|
||||
|
||||
export const jwtConfig = registerAs('jwt', () => ({
|
||||
secret: process.env.JWT_SECRET,
|
||||
accessTokenExpiredTime: process.env.ACCESS_TOKEN_EXPIRED_TIME,
|
||||
refreshTokenExpiredTime: process.env.REFRESH_TOKEN_EXPIRED_TIME,
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { OpensearchConfigModule } from './opensearch-config/opensearch-config.module';
|
||||
export { MailerConfigModule } from './mailer-config/mailer-config.module';
|
||||
export { TypeOrmConfigModule } from './typeorm-config/typeorm-config.module';
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { MailerModule } from '@nestjs-modules/mailer';
|
||||
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MailerModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService<ConfigServiceType>) => {
|
||||
const {
|
||||
host,
|
||||
password,
|
||||
port,
|
||||
username,
|
||||
sender,
|
||||
cipherSpec,
|
||||
opportunisticTLS,
|
||||
tls,
|
||||
} = configService.get('smtp', { infer: true }) ?? {};
|
||||
return {
|
||||
transport: {
|
||||
host,
|
||||
port,
|
||||
tls: { ciphers: cipherSpec },
|
||||
auth:
|
||||
username && password ?
|
||||
{ user: username, pass: password }
|
||||
: undefined,
|
||||
secure: tls,
|
||||
pool: true,
|
||||
},
|
||||
defaults: { from: `"User feedback" <${sender}>` },
|
||||
template: {
|
||||
dir: __dirname + '/templates/',
|
||||
adapter: new HandlebarsAdapter(),
|
||||
options: { strict: true },
|
||||
},
|
||||
opportunisticTLS,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class MailerConfigModule {}
|
||||
@@ -0,0 +1,307 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- Compiled with Bootstrap Email version: 1.3.1 --><meta
|
||||
http-equiv='x-ua-compatible'
|
||||
content='ie=edge'
|
||||
/>
|
||||
<meta name='x-apple-disable-message-reformatting' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta
|
||||
name='format-detection'
|
||||
content='telephone=no, date=no, address=no, email=no'
|
||||
/>
|
||||
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
|
||||
<style type='text/css'>
|
||||
body,table,td{font-family:Helvetica,Arial,sans-serif
|
||||
!important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass
|
||||
p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass
|
||||
div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body
|
||||
a,#MessageViewBody
|
||||
a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-])
|
||||
td{border-spacing:0px;border-collapse:collapse}@media screen and
|
||||
(max-width: 600px){.gap-3.row,.gap-x-3.row{margin-right:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-x-3.row>table>tbody>tr>td{padding-right:12px
|
||||
!important}.gap-3.row,.gap-y-3.row{margin-bottom:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-y-3.row>table>tbody>tr>td{padding-bottom:12px
|
||||
!important}.gap-8.row,.gap-x-8.row{margin-right:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-x-8.row>table>tbody>tr>td{padding-right:32px
|
||||
!important}.gap-8.row,.gap-y-8.row{margin-bottom:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-y-8.row>table>tbody>tr>td{padding-bottom:32px
|
||||
!important}table.gap-3.stack-x>tbody>tr>td{padding-right:12px
|
||||
!important}table.gap-3.stack-y>tbody>tr>td{padding-bottom:12px
|
||||
!important}table.gap-8.stack-x>tbody>tr>td{padding-right:32px
|
||||
!important}table.gap-8.stack-y>tbody>tr>td{padding-bottom:32px
|
||||
!important}.w-full,.w-full>tbody>tr>td{width:100%
|
||||
!important}.w-56,.w-56>tbody>tr>td{width:224px
|
||||
!important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td
|
||||
a{padding:16px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0
|
||||
!important;line-height:0 !important;height:0
|
||||
!important}.s-3>tbody>tr>td{font-size:12px !important;line-height:12px
|
||||
!important;height:12px !important}.s-6>tbody>tr>td{font-size:24px
|
||||
!important;line-height:24px !important;height:24px
|
||||
!important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px
|
||||
!important;height:40px !important}}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
bgcolor='#ffffff'
|
||||
>
|
||||
<table
|
||||
valign='top'
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign='top' align='center'>
|
||||
<table
|
||||
align='center'
|
||||
style='width: 100%; max-width: 600px; margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr style='height: 32px;'>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
class='ax-center'
|
||||
align='center'
|
||||
style='margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; margin: 0;'
|
||||
align='left'
|
||||
>
|
||||
<img
|
||||
class=''
|
||||
width='88'
|
||||
height='100'
|
||||
src='{{baseUrl}}/assets/mailing/email-signup.png'
|
||||
alt='Email Signup'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='s-10 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='40'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-700 text-center text-lg'
|
||||
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
|
||||
align='center'
|
||||
>Sign up to UserFeedback</div>
|
||||
<table
|
||||
class='s-3 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='12'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-400 text-center text-sm'
|
||||
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
|
||||
align='center'
|
||||
>Please sign up using the button below.
|
||||
<br />
|
||||
This link will expire after 24 hours or if it is used
|
||||
once.</div>
|
||||
<table
|
||||
class='s-6 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='24'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='ax-center btn btn-black w-56'
|
||||
align='center'
|
||||
style='border-radius: 6px; border-collapse: separate !important; width: 224px; margin: 0 auto;'
|
||||
width='224'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; border-radius: 6px; width: 224px; margin: 0;'
|
||||
align='center'
|
||||
bgcolor='#000000'
|
||||
width='224'
|
||||
>
|
||||
<a
|
||||
href='{{link}}'
|
||||
style='color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #000000; padding: 8px 12px; border: 1px solid #000000;'
|
||||
>Sign Up</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div
|
||||
class='fw-400 text-secondary text-center text-xs'
|
||||
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
|
||||
align='center'
|
||||
>This is an automated message. Please do not reply to this
|
||||
email.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<table class='s-6' style='width: 100%;' width='100%'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/logo.svg'
|
||||
alt='Logo'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<img
|
||||
width='116'
|
||||
height='18'
|
||||
src='{{baseUrl}}/assets/mailing/title-ufb.png'
|
||||
alt='UserFeedback'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td align='left' valign='center'>
|
||||
<table align='center' style='margin: 0 auto;'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/globe-fill.png'
|
||||
alt='Website'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='https://github.com/line/abc-user-feedback'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/github-mark.png'
|
||||
alt='GitHub'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='mailto:dl_abc_userfeedback@linecorp.com'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/mail-fill.png'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,306 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- Compiled with Bootstrap Email version: 1.3.1 --><meta
|
||||
http-equiv='x-ua-compatible'
|
||||
content='ie=edge'
|
||||
/>
|
||||
<meta name='x-apple-disable-message-reformatting' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta
|
||||
name='format-detection'
|
||||
content='telephone=no, date=no, address=no, email=no'
|
||||
/>
|
||||
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
|
||||
<style type='text/css'>
|
||||
body,table,td{font-family:Helvetica,Arial,sans-serif
|
||||
!important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass
|
||||
p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass
|
||||
div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body
|
||||
a,#MessageViewBody
|
||||
a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-])
|
||||
td{border-spacing:0px;border-collapse:collapse}@media screen and
|
||||
(max-width: 600px){.gap-3.row,.gap-x-3.row{margin-right:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-x-3.row>table>tbody>tr>td{padding-right:12px
|
||||
!important}.gap-3.row,.gap-y-3.row{margin-bottom:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-y-3.row>table>tbody>tr>td{padding-bottom:12px
|
||||
!important}.gap-8.row,.gap-x-8.row{margin-right:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-x-8.row>table>tbody>tr>td{padding-right:32px
|
||||
!important}.gap-8.row,.gap-y-8.row{margin-bottom:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-y-8.row>table>tbody>tr>td{padding-bottom:32px
|
||||
!important}table.gap-3.stack-x>tbody>tr>td{padding-right:12px
|
||||
!important}table.gap-3.stack-y>tbody>tr>td{padding-bottom:12px
|
||||
!important}table.gap-8.stack-x>tbody>tr>td{padding-right:32px
|
||||
!important}table.gap-8.stack-y>tbody>tr>td{padding-bottom:32px
|
||||
!important}.w-full,.w-full>tbody>tr>td{width:100%
|
||||
!important}.w-56,.w-56>tbody>tr>td{width:224px
|
||||
!important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td
|
||||
a{padding:16px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0
|
||||
!important;line-height:0 !important;height:0
|
||||
!important}.s-3>tbody>tr>td{font-size:12px !important;line-height:12px
|
||||
!important;height:12px !important}.s-6>tbody>tr>td{font-size:24px
|
||||
!important;line-height:24px !important;height:24px
|
||||
!important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px
|
||||
!important;height:40px !important}}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
bgcolor='#ffffff'
|
||||
>
|
||||
<table
|
||||
valign='top'
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign='top' align='center'>
|
||||
<table
|
||||
align='center'
|
||||
style='width: 100%; max-width: 600px; margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr style='height: 32px;'>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
class='ax-center'
|
||||
align='center'
|
||||
style='margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; margin: 0;'
|
||||
align='left'
|
||||
>
|
||||
<img
|
||||
width='160'
|
||||
height='160'
|
||||
src='{{baseUrl}}/assets/mailing/email-reset.png'
|
||||
alt='Reset Password'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='s-10 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='40'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-700 text-center text-lg'
|
||||
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
|
||||
align='center'
|
||||
>Reset Password</div>
|
||||
<table
|
||||
class='s-3 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='12'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-400 text-center text-sm'
|
||||
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
|
||||
align='center'
|
||||
>Please change your password through the button below.
|
||||
<br />
|
||||
This link will expire after 24 hours or if it is used
|
||||
once.</div>
|
||||
<table
|
||||
class='s-6 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='24'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='ax-center btn btn-black w-56'
|
||||
align='center'
|
||||
style='border-radius: 6px; border-collapse: separate !important; width: 224px; margin: 0 auto;'
|
||||
width='224'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; border-radius: 6px; width: 224px; margin: 0;'
|
||||
align='center'
|
||||
bgcolor='#000000'
|
||||
width='224'
|
||||
>
|
||||
<a
|
||||
href='{{link}}'
|
||||
style='color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #000000; padding: 8px 12px; border: 1px solid #000000;'
|
||||
>Change Password</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div
|
||||
class='fw-400 text-secondary text-center text-xs'
|
||||
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
|
||||
align='center'
|
||||
>This is an automated message. Please do not reply to this
|
||||
email.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<table class='s-6' style='width: 100%;' width='100%'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/logo.svg'
|
||||
alt='Logo'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<img
|
||||
width='116'
|
||||
height='18'
|
||||
src='{{baseUrl}}/assets/mailing/title-ufb.png'
|
||||
alt='UserFeedback'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td align='left' valign='center'>
|
||||
<table align='center' style='margin: 0 auto;'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/globe-fill.png'
|
||||
alt='Website'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='https://github.com/line/abc-user-feedback'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/github-mark.png'
|
||||
alt='GitHub'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='mailto:dl_abc_userfeedback@linecorp.com'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/mail-fill.png'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
<html>
|
||||
<head>
|
||||
<!-- Compiled with Bootstrap Email version: 1.3.1 --><meta
|
||||
http-equiv='x-ua-compatible'
|
||||
content='ie=edge'
|
||||
/>
|
||||
<meta name='x-apple-disable-message-reformatting' />
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
||||
<meta
|
||||
name='format-detection'
|
||||
content='telephone=no, date=no, address=no, email=no'
|
||||
/>
|
||||
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
|
||||
<style type='text/css'>
|
||||
body,table,td{font-family:Helvetica,Arial,sans-serif
|
||||
!important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass
|
||||
p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass
|
||||
div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body
|
||||
a,#MessageViewBody
|
||||
a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-])
|
||||
td{border-spacing:0px;border-collapse:collapse}@media screen and
|
||||
(max-width: 600px){.gap-3.row,.gap-x-3.row{margin-right:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-x-3.row>table>tbody>tr>td{padding-right:12px
|
||||
!important}.gap-3.row,.gap-y-3.row{margin-bottom:-12px
|
||||
!important}.gap-3.row>table>tbody>tr>td,.gap-y-3.row>table>tbody>tr>td{padding-bottom:12px
|
||||
!important}.gap-8.row,.gap-x-8.row{margin-right:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-x-8.row>table>tbody>tr>td{padding-right:32px
|
||||
!important}.gap-8.row,.gap-y-8.row{margin-bottom:-32px
|
||||
!important}.gap-8.row>table>tbody>tr>td,.gap-y-8.row>table>tbody>tr>td{padding-bottom:32px
|
||||
!important}table.gap-3.stack-x>tbody>tr>td{padding-right:12px
|
||||
!important}table.gap-3.stack-y>tbody>tr>td{padding-bottom:12px
|
||||
!important}table.gap-8.stack-x>tbody>tr>td{padding-right:32px
|
||||
!important}table.gap-8.stack-y>tbody>tr>td{padding-bottom:32px
|
||||
!important}.w-full,.w-full>tbody>tr>td{width:100%
|
||||
!important}.w-56,.w-56>tbody>tr>td{width:224px
|
||||
!important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td
|
||||
a{padding:16px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0
|
||||
!important;line-height:0 !important;height:0
|
||||
!important}.s-3>tbody>tr>td{font-size:12px !important;line-height:12px
|
||||
!important;height:12px !important}.s-6>tbody>tr>td{font-size:24px
|
||||
!important;line-height:24px !important;height:24px
|
||||
!important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px
|
||||
!important;height:40px !important}}
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
bgcolor='#ffffff'
|
||||
>
|
||||
<table
|
||||
valign='top'
|
||||
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign='top' align='center'>
|
||||
<table
|
||||
align='center'
|
||||
style='width: 100%; max-width: 600px; margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr style='height: 32px;'>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div>
|
||||
<table
|
||||
class='ax-center'
|
||||
align='center'
|
||||
style='margin: 0 auto;'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; margin: 0;'
|
||||
align='left'
|
||||
>
|
||||
<img
|
||||
width='160'
|
||||
height='160'
|
||||
src='{{baseUrl}}/assets/mailing/email-reset.png'
|
||||
alt='Reset Password'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table
|
||||
class='s-10 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='40'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-700 text-center text-lg'
|
||||
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
|
||||
align='center'
|
||||
>Authentication Code</div>
|
||||
<table
|
||||
class='s-3 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='12'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-400 text-center text-sm'
|
||||
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
|
||||
align='center'
|
||||
>Please check the authentication code below.
|
||||
<br />
|
||||
This link will expire after 5 minutes or if it is used
|
||||
once.</div>
|
||||
<table
|
||||
class='s-6 w-full'
|
||||
style='width: 100%;'
|
||||
width='100%'
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
height='24'
|
||||
>
|
||||
 
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div
|
||||
class='fw-700 text-center text-2xl'
|
||||
style='font-size: 24px; line-height: 28.8px; font-weight: 700 !important;'
|
||||
align='center'
|
||||
>{{code}}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<div
|
||||
class='fw-400 text-secondary text-center text-xs'
|
||||
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
|
||||
align='center'
|
||||
>This is an automated message. Please do not reply to this
|
||||
email.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
|
||||
align='left'
|
||||
width='100%'
|
||||
>
|
||||
<table class='s-6' style='width: 100%;' width='100%'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/logo.svg'
|
||||
alt='Logo'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<img
|
||||
width='116'
|
||||
height='18'
|
||||
src='{{baseUrl}}/assets/mailing/title-ufb.png'
|
||||
alt='UserFeedback'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
<td align='left' valign='center'>
|
||||
<table align='center' style='margin: 0 auto;'>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/globe-fill.png'
|
||||
alt='Website'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 12px; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='https://github.com/line/abc-user-feedback'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/github-mark.png'
|
||||
alt='GitHub'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
<td
|
||||
style='padding-right: 0; margin: 0;'
|
||||
align='left'
|
||||
valign='top'
|
||||
>
|
||||
<a
|
||||
href='mailto:dl_abc_userfeedback@linecorp.com'
|
||||
style='color: #0d6efd;'
|
||||
>
|
||||
<img
|
||||
width='16'
|
||||
height='16'
|
||||
src='{{baseUrl}}/assets/mailing/mail-fill.png'
|
||||
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
|
||||
/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Client, NodeOptions } from '@opensearch-project/opensearch';
|
||||
|
||||
import type { ConfigServiceType } from '@/types/config-service.type';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: 'OPENSEARCH_CLIENT',
|
||||
useFactory: (
|
||||
configService: ConfigService<ConfigServiceType>,
|
||||
): Client | undefined => {
|
||||
const {
|
||||
use,
|
||||
node,
|
||||
password,
|
||||
username,
|
||||
}: {
|
||||
use: boolean;
|
||||
node: string | string[] | NodeOptions | NodeOptions[];
|
||||
password: string;
|
||||
username: string;
|
||||
} = configService.get('opensearch', {
|
||||
infer: true,
|
||||
}) ?? { use: false, node: '', password: '', username: '' };
|
||||
return use ?
|
||||
new Client({ node, auth: { username, password } })
|
||||
: undefined;
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
exports: ['OPENSEARCH_CLIENT'],
|
||||
})
|
||||
export class OpensearchConfigModule {}
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class Init1692159572819 implements MigrationInterface {
|
||||
name = 'Init1692159572819';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`tenant\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`site_name\` varchar(50) NOT NULL, \`description\` varchar(255) NULL, \`use_email\` tinyint NOT NULL DEFAULT 1, \`is_private\` tinyint NOT NULL DEFAULT 0, \`is_restrict_domain\` tinyint NOT NULL DEFAULT 0, \`allow_domains\` text NULL, \`use_o_auth\` tinyint NOT NULL DEFAULT 0, \`oauth_config\` json NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`issues\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`description\` varchar(255) NULL, \`status\` enum ('INIT', 'ON_REVIEW', 'IN_PROGRESS', 'RESOLVED', 'PENDING') NOT NULL DEFAULT 'INIT', \`external_issue_id\` varchar(255) NULL, \`feedback_count\` int NOT NULL DEFAULT '0', \`project_id\` int NULL, INDEX \`IDX_b7fd6df20da19c630741ea9045\` (\`status\`), INDEX \`IDX_db94fcc9ef9f968b43ec5d2b2a\` (\`feedback_count\`), INDEX \`IDX_8e64309f790aa4270b955a9947\` (\`project_id\`, \`created_at\`), UNIQUE INDEX \`IDX_b711d3eb6f21e35f5a0623dbe2\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`feedbacks\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`raw_data\` json NOT NULL, \`additional_data\` json NULL, \`channel_id\` int NULL, INDEX \`IDX_a640975f8ccf17d9337d4ff828\` (\`created_at\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`options\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`key\` varchar(255) NOT NULL, \`field_id\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`fields\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`key\` varchar(255) NOT NULL, \`description\` varchar(255) NULL, \`format\` enum ('text', 'keyword', 'number', 'boolean', 'select', 'multiSelect', 'date') NOT NULL, \`type\` enum ('DEFAULT', 'ADMIN', 'API') NOT NULL, \`status\` enum ('ACTIVE', 'INACTIVE') NOT NULL, \`channel_id\` int NULL, INDEX \`IDX_4b2181db660323e7ae856adeae\` (\`created_at\`), UNIQUE INDEX \`field-name-unique\` (\`name\`, \`channel_id\`), UNIQUE INDEX \`field-key-unique\` (\`key\`, \`channel_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`channels\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`description\` varchar(255) NULL, \`project_id\` int NULL, INDEX \`IDX_1233531abfb8d56d2a15050143\` (\`name\`, \`created_at\`), UNIQUE INDEX \`project-name-unique\` (\`name\`, \`project_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`projects\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`description\` varchar(255) NULL, \`tenant_id\` int NULL, UNIQUE INDEX \`IDX_2187088ab5ef2a918473cb9900\` (\`name\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`roles\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`permissions\` text NOT NULL, \`project_id\` int NULL, INDEX \`IDX_f4f2789197a3cbbc0182396b26\` (\`name\`, \`project_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`members\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`role_id\` int NULL, \`user_id\` int NULL, UNIQUE INDEX \`IDX_858f5ec01bcfe14ab3f2a328dc\` (\`role_id\`, \`user_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`users\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`email\` varchar(320) NULL, \`name\` varchar(255) NULL, \`department\` varchar(255) NULL, \`state\` enum ('Active', 'Blocked') NOT NULL DEFAULT 'Active', \`hash_password\` varchar(255) NULL, \`type\` enum ('SUPER', 'GENERAL') NOT NULL DEFAULT 'GENERAL', \`sign_up_method\` enum ('EMAIL', 'OAUTH') NOT NULL DEFAULT 'EMAIL', UNIQUE INDEX \`IDX_1301b11757c5b489adc8bc05e4\` (\`email\`, \`sign_up_method\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`histories\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`entity_name\` enum ('ApiKey', 'Channel', 'Feedback', 'Field', 'IssueTracker', 'Issue', 'Member', 'Option', 'Project', 'Role', 'Tenant', 'User', 'FeedbackIssue', 'Code') NOT NULL, \`entity_id\` decimal NOT NULL, \`action\` enum ('Create', 'Update', 'Delete', 'SoftDelete', 'Download', 'Recover') NOT NULL, \`entity\` json NOT NULL, \`user_id\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`codes\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`type\` enum ('EMAIL_VEIRIFICATION', 'RESET_PASSWORD', 'USER_INVITATION') NOT NULL, \`key\` varchar(255) NOT NULL, \`code\` varchar(255) NOT NULL, \`data\` varchar(255) NULL, \`is_verified\` tinyint NOT NULL DEFAULT 0, \`expired_at\` datetime NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`api_keys\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`value\` varchar(255) NOT NULL, \`project_id\` int NULL, UNIQUE INDEX \`IDX_2662a95fc4dd64493ca686a82f\` (\`value\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`issue_trackers\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`data\` json NULL, \`project_id\` int NULL, UNIQUE INDEX \`REL_0d000918b0c670b7d2488257dd\` (\`project_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`feedbacks_issues_issues\` (\`feedbacks_id\` int NOT NULL, \`issues_id\` int NOT NULL, INDEX \`IDX_3435079c319679ba3aaccd806b\` (\`feedbacks_id\`), INDEX \`IDX_6d6f24cf306a31c0af7b50973a\` (\`issues_id\`), PRIMARY KEY (\`feedbacks_id\`, \`issues_id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issues\` ADD CONSTRAINT \`FK_11f35e8296e10c229e7b68c68d4\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` ADD CONSTRAINT \`FK_4adbe5e6c46eba8a93a0265a078\` FOREIGN KEY (\`channel_id\`) REFERENCES \`channels\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`options\` ADD CONSTRAINT \`FK_dc520ce6f54769336c4afa5e9b9\` FOREIGN KEY (\`field_id\`) REFERENCES \`fields\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD CONSTRAINT \`FK_da856f4b147eb542917c5968c43\` FOREIGN KEY (\`channel_id\`) REFERENCES \`channels\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` ADD CONSTRAINT \`FK_63c4e21cafd9504a7c139144d1c\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` ADD CONSTRAINT \`FK_7393a03ef67e2ea91b81faa95dd\` FOREIGN KEY (\`tenant_id\`) REFERENCES \`tenant\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`roles\` ADD CONSTRAINT \`FK_cb48212dfe65dfe431d486034d2\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`members\` ADD CONSTRAINT \`FK_274c5ebb3c595f5a56f1f8fba9a\` FOREIGN KEY (\`role_id\`) REFERENCES \`roles\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`members\` ADD CONSTRAINT \`FK_da404b5fd9c390e25338996e2d1\` FOREIGN KEY (\`user_id\`) REFERENCES \`users\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`api_keys\` ADD CONSTRAINT \`FK_f5de07dbb229225e2be643ff3d0\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issue_trackers\` ADD CONSTRAINT \`FK_0d000918b0c670b7d2488257dd7\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks_issues_issues\` ADD CONSTRAINT \`FK_3435079c319679ba3aaccd806b1\` FOREIGN KEY (\`feedbacks_id\`) REFERENCES \`feedbacks\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks_issues_issues\` ADD CONSTRAINT \`FK_6d6f24cf306a31c0af7b50973ab\` FOREIGN KEY (\`issues_id\`) REFERENCES \`issues\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks_issues_issues\` DROP FOREIGN KEY \`FK_6d6f24cf306a31c0af7b50973ab\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks_issues_issues\` DROP FOREIGN KEY \`FK_3435079c319679ba3aaccd806b1\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issue_trackers\` DROP FOREIGN KEY \`FK_0d000918b0c670b7d2488257dd7\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`api_keys\` DROP FOREIGN KEY \`FK_f5de07dbb229225e2be643ff3d0\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`members\` DROP FOREIGN KEY \`FK_da404b5fd9c390e25338996e2d1\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`members\` DROP FOREIGN KEY \`FK_274c5ebb3c595f5a56f1f8fba9a\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`roles\` DROP FOREIGN KEY \`FK_cb48212dfe65dfe431d486034d2\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` DROP FOREIGN KEY \`FK_7393a03ef67e2ea91b81faa95dd\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` DROP FOREIGN KEY \`FK_63c4e21cafd9504a7c139144d1c\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` DROP FOREIGN KEY \`FK_da856f4b147eb542917c5968c43\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`options\` DROP FOREIGN KEY \`FK_dc520ce6f54769336c4afa5e9b9\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` DROP FOREIGN KEY \`FK_4adbe5e6c46eba8a93a0265a078\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issues\` DROP FOREIGN KEY \`FK_11f35e8296e10c229e7b68c68d4\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_6d6f24cf306a31c0af7b50973a\` ON \`feedbacks_issues_issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_3435079c319679ba3aaccd806b\` ON \`feedbacks_issues_issues\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`feedbacks_issues_issues\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`REL_0d000918b0c670b7d2488257dd\` ON \`issue_trackers\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`issue_trackers\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_2662a95fc4dd64493ca686a82f\` ON \`api_keys\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`api_keys\``);
|
||||
await queryRunner.query(`DROP TABLE \`codes\``);
|
||||
await queryRunner.query(`DROP TABLE \`histories\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_1301b11757c5b489adc8bc05e4\` ON \`users\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`users\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_858f5ec01bcfe14ab3f2a328dc\` ON \`members\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`members\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_f4f2789197a3cbbc0182396b26\` ON \`roles\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`roles\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_2187088ab5ef2a918473cb9900\` ON \`projects\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`projects\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`project-name-unique\` ON \`channels\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_1233531abfb8d56d2a15050143\` ON \`channels\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`channels\``);
|
||||
await queryRunner.query(`DROP INDEX \`field-key-unique\` ON \`fields\``);
|
||||
await queryRunner.query(`DROP INDEX \`field-name-unique\` ON \`fields\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_4b2181db660323e7ae856adeae\` ON \`fields\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`fields\``);
|
||||
await queryRunner.query(`DROP TABLE \`options\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_a640975f8ccf17d9337d4ff828\` ON \`feedbacks\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`feedbacks\``);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_b711d3eb6f21e35f5a0623dbe2\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_8e64309f790aa4270b955a9947\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_db94fcc9ef9f968b43ec5d2b2a\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_b7fd6df20da19c630741ea9045\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`issues\``);
|
||||
await queryRunner.query(`DROP TABLE \`tenant\``);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class IssueNameUnique1692690482919 implements MigrationInterface {
|
||||
name = 'IssueNameUnique1692690482919';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_b711d3eb6f21e35f5a0623dbe2\` ON \`issues\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX \`issue-name-unique\` ON \`issues\` (\`name\`, \`project_id\`)`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX \`issue-name-unique\` ON \`issues\``);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX \`IDX_b711d3eb6f21e35f5a0623dbe2\` ON \`issues\` (\`name\`)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class FeedbackStatistics1700795163534 implements MigrationInterface {
|
||||
name = 'FeedbackStatistics1700795163534';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`feedback_statistics\` (\`id\` int NOT NULL AUTO_INCREMENT, \`date\` date NOT NULL, \`count\` int NOT NULL DEFAULT '0', \`channel_id\` int NULL, UNIQUE INDEX \`channel-date-unique\` (\`channel_id\`, \`date\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedback_statistics\` ADD CONSTRAINT \`FK_7250a09c7ee486d1d24938a7054\` FOREIGN KEY (\`channel_id\`) REFERENCES \`channels\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedback_statistics\` DROP FOREIGN KEY \`FK_7250a09c7ee486d1d24938a7054\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`channel-date-unique\` ON \`feedback_statistics\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`feedback_statistics\``);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ProjectTimezoneOffset1700795948817 implements MigrationInterface {
|
||||
name = 'ProjectTimezoneOffset1700795948817';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` ADD \`timezone_offset\` varchar(255) NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` DROP COLUMN \`timezone_offset\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class IssueStatistics1701090850194 implements MigrationInterface {
|
||||
name = 'IssueStatistics1701090850194';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`issue_statistics\` (\`id\` int NOT NULL AUTO_INCREMENT, \`date\` date NOT NULL, \`count\` int NOT NULL DEFAULT '0', \`project_id\` int NULL, UNIQUE INDEX \`project-date-unique\` (\`project_id\`, \`date\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issue_statistics\` ADD CONSTRAINT \`FK_86e6ee861d8895004659b4fe076\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`issue_statistics\` DROP FOREIGN KEY \`FK_86e6ee861d8895004659b4fe076\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`project-date-unique\` ON \`issue_statistics\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`issue_statistics\``);
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class FeedbackIssueStatistics1701234953280
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'FeedbackIssueStatistics1701234953280';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`feedback_issue_statistics\` (\`id\` int NOT NULL AUTO_INCREMENT, \`date\` date NOT NULL, \`feedback_count\` int NOT NULL DEFAULT '0', \`issue_id\` int NULL, UNIQUE INDEX \`issue-date-unique\` (\`issue_id\`, \`date\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedback_issue_statistics\` ADD CONSTRAINT \`FK_f90e8299de4ac2a05d3b6cbb2a6\` FOREIGN KEY (\`issue_id\`) REFERENCES \`issues\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedback_issue_statistics\` DROP FOREIGN KEY \`FK_f90e8299de4ac2a05d3b6cbb2a6\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`issue-date-unique\` ON \`feedback_issue_statistics\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`feedback_issue_statistics\``);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ImageConfig1701914129112 implements MigrationInterface {
|
||||
name = 'ImageConfig1701914129112';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` ADD \`image_config\` json NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`channels\` DROP COLUMN \`image_config\``,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ImageFormat1701931484534 implements MigrationInterface {
|
||||
name = 'ImageFormat1701931484534';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'boolean', 'select', 'multiSelect', 'date', 'image') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'boolean', 'select', 'multiSelect', 'date') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ProjectTimezoneOffsetDefault1702536442621
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'ProjectTimezoneOffsetDefault1702536442621';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` CHANGE \`timezone_offset\` \`timezone_offset\` varchar(255) NOT NULL DEFAULT '+00:00'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` CHANGE \`timezone_offset\` \`stimezone_offset\` varchar(255) NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ProjectTimezoneJson1705398750913 implements MigrationInterface {
|
||||
name = 'ProjectTimezoneJson1705398750913';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` CHANGE \`timezone_offset\` \`timezone\` varchar(255) NULL DEFAULT '+00:00'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` DROP COLUMN \`timezone\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` ADD \`timezone\` varchar(255) NOT NULL DEFAULT '{"countryCode":"KR","name":"Asia/Seoul","offset":"+09:00"}'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` DROP COLUMN \`timezone\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` ADD \`timezone\` varchar(255) NULL DEFAULT '+00:00'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`projects\` CHANGE \`timezone\` \`timezone_offset\` varchar(255) NULL DEFAULT '+00:00'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DeprecateBooleanField1707356935078 implements MigrationInterface {
|
||||
name = 'DeprecateBooleanField1707356935078';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'image') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'boolean', 'select', 'multiSelect', 'date', 'image') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CodeVerificationTryCount1707979877289
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CodeVerificationTryCount1707979877289';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`codes\` ADD \`try_count\` int NOT NULL DEFAULT '0'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`codes\` DROP COLUMN \`try_count\``);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ChangeFieldFormatName1707979877290 implements MigrationInterface {
|
||||
name = 'ChangeFieldFormatName1707979877290';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'images') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`format\` \`format\` enum ('text', 'keyword', 'number', 'select', 'multiSelect', 'date', 'image') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class WebhookAndEvent1708666764079 implements MigrationInterface {
|
||||
name = 'WebhookAndEvent1708666764079';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`events\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`status\` enum ('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE', \`type\` enum ('FEEDBACK_CREATION', 'ISSUE_CREATION', 'ISSUE_STATUS_CHANGE', 'ISSUE_ADDITION') NOT NULL, \`webhook_id\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`webhooks\` (\`id\` int NOT NULL AUTO_INCREMENT, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`deleted_at\` datetime(6) NULL, \`name\` varchar(255) NOT NULL, \`url\` varchar(255) NOT NULL, \`status\` enum ('ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'ACTIVE', \`project_id\` int NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`events_channels_channels\` (\`events_id\` int NOT NULL, \`channels_id\` int NOT NULL, INDEX \`IDX_97c39787187b453ae616ae1cb5\` (\`events_id\`), INDEX \`IDX_47c9e3e834366fe3217b6d2741\` (\`channels_id\`), PRIMARY KEY (\`events_id\`, \`channels_id\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events\` ADD CONSTRAINT \`FK_81282a308a195ff5a7e6ba54fc3\` FOREIGN KEY (\`webhook_id\`) REFERENCES \`webhooks\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`webhooks\` ADD CONSTRAINT \`FK_8b545b4c86913152b9da6e04b08\` FOREIGN KEY (\`project_id\`) REFERENCES \`projects\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events_channels_channels\` ADD CONSTRAINT \`FK_97c39787187b453ae616ae1cb58\` FOREIGN KEY (\`events_id\`) REFERENCES \`events\`(\`id\`) ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events_channels_channels\` ADD CONSTRAINT \`FK_47c9e3e834366fe3217b6d2741f\` FOREIGN KEY (\`channels_id\`) REFERENCES \`channels\`(\`id\`) ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events_channels_channels\` DROP FOREIGN KEY \`FK_47c9e3e834366fe3217b6d2741f\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events_channels_channels\` DROP FOREIGN KEY \`FK_97c39787187b453ae616ae1cb58\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`webhooks\` DROP FOREIGN KEY \`FK_8b545b4c86913152b9da6e04b08\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`events\` DROP FOREIGN KEY \`FK_81282a308a195ff5a7e6ba54fc3\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_47c9e3e834366fe3217b6d2741\` ON \`events_channels_channels\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX \`IDX_97c39787187b453ae616ae1cb5\` ON \`events_channels_channels\``,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE \`events_channels_channels\``);
|
||||
await queryRunner.query(`DROP TABLE \`webhooks\``);
|
||||
await queryRunner.query(`DROP TABLE \`events\``);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddWebhookInHistory1709172706829 implements MigrationInterface {
|
||||
name = 'AddWebhookInHistory1709172706829';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`histories\` CHANGE \`entity_name\` \`entity_name\` enum ('ApiKey', 'Channel', 'Feedback', 'Field', 'IssueTracker', 'Issue', 'Member', 'Option', 'Project', 'Role', 'Tenant', 'User', 'FeedbackIssue', 'Code', 'Webhook') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`histories\` CHANGE \`entity_name\` \`entity_name\` enum ('ApiKey', 'Channel', 'Feedback', 'Field', 'IssueTracker', 'Issue', 'Member', 'Option', 'Project', 'Role', 'Tenant', 'User', 'FeedbackIssue', 'Code') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SchedulerLock1709803978757 implements MigrationInterface {
|
||||
name = 'SchedulerLock1709803978757';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE \`scheduler_locks\` (\`lock_type\` enum ('FEEDBACK_STATISTICS', 'ISSUE_STATISTICS', 'FEEDBACK_ISSUE_STATISTICS', 'FEEDBACK_COUNT') NOT NULL, \`server_id\` varchar(255) NOT NULL, \`timestamp\` datetime NOT NULL, PRIMARY KEY (\`lock_type\`)) ENGINE=InnoDB`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE \`scheduler_locks\``);
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class DeprecateFieldFormat1713716840764 implements MigrationInterface {
|
||||
name = 'DeprecateFieldFormat1713716840764';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`feedbacks\` ADD \`data\` json`);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`feedbacks\` SET \`data\` = JSON_MERGE(COALESCE(\`raw_data\`, '{}'), COALESCE(\`additional_data\`, '{}'))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` CHANGE \`data\` \`data\` json NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` DROP COLUMN \`raw_data\``,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` DROP COLUMN \`additional_data\``,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD \`property\` enum ('READ_ONLY', 'EDITABLE')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`property\` = 'EDITABLE' WHERE \`type\` = 'ADMIN'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`property\` = 'EDITABLE' WHERE \`type\` = 'DEFAULT' AND \`key\` = 'issues'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`property\` = 'READ_ONLY' WHERE \`type\` = 'API'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`property\` = 'READ_ONLY' WHERE \`type\` = 'DEFAULT' AND \`key\` IN ('id', 'createdAt', 'updatedAt')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`property\` \`property\` enum ('READ_ONLY', 'EDITABLE') NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE \`fields\` DROP COLUMN \`type\``);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` ADD \`additional_data\` json NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` ADD \`raw_data\` json NULL`,
|
||||
);
|
||||
await queryRunner.query(`UPDATE \`feedbacks\` SET \`raw_data\` = \`data\``);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`feedbacks\` CHANGE \`raw_data\` \`raw_data\` json NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE \`feedbacks\` DROP COLUMN \`data\``);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD \`type\` enum ('DEFAULT', 'ADMIN', 'API')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`type\` = 'ADMIN' WHERE \`property\` = 'EDITABLE'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`type\` = 'API' WHERE \`property\` = 'READ_ONLY'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`UPDATE \`fields\` SET \`type\` = 'DEFAULT' WHERE \`key\` IN ('id', 'createdAt', 'updatedAt', 'issues')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` CHANGE \`type\` \`type\` enum ('DEFAULT', 'ADMIN', 'API') NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE \`fields\` DROP COLUMN \`property\``);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddTokenOnWebhook1720760282371 implements MigrationInterface {
|
||||
name = 'AddTokenOnWebhook1720760282371';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`webhooks\` ADD \`token\` varchar(255) NULL DEFAULT NULL AFTER \`url\``,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`webhooks\` DROP COLUMN \`token\``);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddOrderOnField1725935382221 implements MigrationInterface {
|
||||
name = 'AddOrderOnField1725935382221';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`fields\` ADD \`order\` int NOT NULL DEFAULT '0'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE \`fields\` DROP COLUMN \`order\``);
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright 2025 LY Corporation
|
||||
*
|
||||
* LY Corporation licenses this file to you under the Apache License,
|
||||
* version 2.0 (the "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
* License for the specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class ModifySchedulerLockEnum1728522901760
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'ModifySchedulerLockEnum1728522901760';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`scheduler_locks\` CHANGE \`lock_type\` \`lock_type\` enum ('FEEDBACK_STATISTICS', 'ISSUE_STATISTICS', 'FEEDBACK_ISSUE_STATISTICS', 'FEEDBACK_COUNT', 'FEEDBACK_DELETE') NOT NULL`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE \`scheduler_locks\` CHANGE \`lock_type\` \`lock_type\` enum ('FEEDBACK_STATISTICS', 'ISSUE_STATISTICS', 'FEEDBACK_ISSUE_STATISTICS', 'FEEDBACK_COUNT') NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user