first commit
CI / typecheck (push) Successful in 1m8s
CI / format (push) Failing after 1m6s
CI / lint (push) Failing after 49s
CI / test (push) Failing after 1m8s

This commit is contained in:
SDI
2026-07-15 18:05:12 +09:00
commit 12e4f17b62
4633 changed files with 817125 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
**/node_modules
**/.next
**/dist
**/.env*
!apps/web/.env.build
docker
volumes
+21
View File
@@ -0,0 +1,21 @@
body:
- type: markdown
attributes:
value: |
Thank you for taking the time to file a feature request. Please fill out this form as completely as possible.
- type: textarea
attributes:
label: Describe the feature you'd like to request
description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed.
validations:
required: true
- type: textarea
attributes:
label: Describe the solution you'd like to see
description: Please describe the solution you would like to see. Adding example usage is a good way to provide context.
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here.
+37
View File
@@ -0,0 +1,37 @@
name: 🐞 Bug Report
description: Create a bug report to help us improve
title: "bug: "
labels: ["🐞❔ unconfirmed bug"]
body:
- type: textarea
attributes:
label: Provide environment information
description: |
Run this command in your project root and paste the results in a code block:
```bash
npx envinfo --system --binaries
```
validations:
required: true
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of the bug, as well as what you expected to happen when encountering it.
validations:
required: true
- type: input
attributes:
label: Link to reproduction
description: Please provide a link to a reproduction of the bug. Issues without a reproduction repo may be ignored.
validations:
required: true
- type: textarea
attributes:
label: To reproduce
description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc.
validations:
required: true
- type: textarea
attributes:
label: Additional information
description: Add any other information related to the bug here, screenshots if applicable.
+7
View File
@@ -0,0 +1,7 @@
contact_links:
- name: Ask a question
url: https://github.com/line/abc-user-feedback/discussions
about: Ask questions and discuss with other community members
- name: Feature request
url: https://github.com/line/abc-user-feedback/discussions/new?category=ideas
about: Feature requests should be opened as discussions
+82
View File
@@ -0,0 +1,82 @@
name: CI
on:
pull_request:
branches: ['*']
push:
branches: ['main']
merge_group:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
FORCE_COLOR: 3
CI: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup
uses: ./tooling/github/setup
- name: Lint
run: pnpm lint && pnpm lint:ws
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup
uses: ./tooling/github/setup
- name: Format
run: pnpm format
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup
uses: ./tooling/github/setup
- name: Typecheck
run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup
uses: ./tooling/github/setup
- name: setup environment variables (with opensearch)
run: |
echo "JWT_SECRET=${{ vars.TEST_JWT_SECRET }}" >> ./apps/api/.env.test
echo "OPENSEARCH_USE=true" >> ./apps/api/.env.test
echo "OPENSEARCH_NODE=http://localhost:9200" >> ./apps/api/.env.test
echo "SMTP_HOST='localhost'" >> ./apps/api/.env.test
echo "SMTP_PORT=25" >> ./apps/api/.env.test
echo "SMTP_SENDER='user@feedback.com'" >> ./apps/api/.env.test
- name: Run Tests
run: pnpm test
- name: setup environment variables (without opensearch)
run: |
rm ./apps/api/.env.test
echo "JWT_SECRET=${{ vars.TEST_JWT_SECRET }}" >> ./apps/api/.env.test
echo "OPENSEARCH_USE=false" >> ./apps/api/.env.test
echo "SMTP_HOST='localhost'" >> ./apps/api/.env.test
echo "SMTP_PORT=25" >> ./apps/api/.env.test
echo "SMTP_SENDER='user@feedback.com'" >> ./apps/api/.env.test
- name: Run Tests
run: pnpm test
+57
View File
@@ -0,0 +1,57 @@
name: Docker Dev Image CI
on:
push:
tags:
- '**-dev'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver: docker-container
- name: Docker meta for API
id: api-meta
uses: docker/metadata-action@v6
with:
images: line/abc-user-feedback-api
bake-target: api
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
- name: Docker meta for Web
id: web-meta
uses: docker/metadata-action@v6
with:
images: line/abc-user-feedback-web
bake-target: web
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Bake and push multi-platform Docker images
uses: docker/bake-action@v7
with:
files: |
./docker/docker-bake.hcl
push: true
set: |
api.tags=${{ steps.api-meta.outputs.tags }}
web.tags=${{ steps.web-meta.outputs.tags }}
+58
View File
@@ -0,0 +1,58 @@
name: Docker Prod Image CI
on:
push:
tags:
- '*'
- '!**-beta'
- '!**-dev'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
with:
driver: docker-container
- name: Docker meta for API
id: api-meta
uses: docker/metadata-action@v6
with:
images: line/abc-user-feedback-api
bake-target: api
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
- name: Docker meta for Web
id: web-meta
uses: docker/metadata-action@v6
with:
images: line/abc-user-feedback-web
bake-target: web
tags: |
type=ref,event=branch
type=ref,event=tag
type=semver,pattern={{version}}
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Bake and push multi-platform Docker images
uses: docker/bake-action@v7
with:
files: |
./docker/docker-bake.hcl
cwd://${{ steps.api-meta.outputs.bake-file }}
cwd://${{ steps.web-meta.outputs.bake-file }}
push: true
+67
View File
@@ -0,0 +1,67 @@
name: E2E Tests
on:
pull_request:
branches: [dev, main]
jobs:
e2e-test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0.39
env:
MYSQL_ROOT_PASSWORD: userfeedback
MYSQL_DATABASE: e2e
MYSQL_USER: userfeedback
MYSQL_PASSWORD: userfeedback
TZ: UTC
ports:
- 13307:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
smtp:
image: rnwood/smtp4dev:v3
ports:
- 5080:80
- 25:25
- 143:143
opensearch:
image: opensearchproject/opensearch:2.4.1
env:
discovery.type: single-node
bootstrap.memory_lock: 'true'
plugins.security.disabled: 'true'
options: >-
--health-cmd="curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"'"
--health-interval=10s
--health-timeout=5s
--health-retries=3
ports:
- 9200:9200
steps:
- uses: actions/checkout@v5
- name: Setup
uses: ./tooling/github/setup
- name: Cache Playwright browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('**/pnpm-lock.yaml') }}
- name: Install Playwright Browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Run e2e tests
run: |
CI=true pnpm test:e2e
- uses: actions/upload-artifact@v4
if: always() && !cancelled()
with:
name: playwright-report
path: apps/e2e/playwright-report/
retention-days: 7
+77
View File
@@ -0,0 +1,77 @@
name: Integration Tests
on:
pull_request:
branches: [dev, main]
jobs:
integration-test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: userfeedback
MYSQL_DATABASE: e2e
MYSQL_USER: userfeedback
MYSQL_PASSWORD: userfeedback
TZ: UTC
ports:
- 13307:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=3
smtp:
image: rnwood/smtp4dev:v3
ports:
- 5080:80
- 25:25
- 143:143
opensearch:
image: opensearchproject/opensearch:2.17.1
env:
discovery.type: single-node
bootstrap.memory_lock: 'true'
plugins.security.disabled: 'true'
OPENSEARCH_INITIAL_ADMIN_PASSWORD: 'UserFeedback123!@#'
options: >-
--health-cmd="curl -s http://localhost:9200/_cluster/health | grep -q '\"status\":\"green\"'"
--health-interval=10s
--health-timeout=5s
--health-retries=3
ports:
- 9200:9200
steps:
- name: Check out repository code
uses: actions/checkout@v5
- name: Setup integration test (with opensearch)
run: |
npm install -g corepack@latest
corepack enable
pnpm install --frozen-lockfile
pnpm build
echo "JWT_SECRET=DEV" >> ./apps/api/.env
echo "OPENSEARCH_USE=true" >> ./apps/api/.env
echo "OPENSEARCH_NODE=http://localhost:9200" >> ./apps/api/.env
echo "SMTP_HOST='localhost'" >> ./apps/api/.env
echo "SMTP_PORT=25" >> ./apps/api/.env
echo "SMTP_SENDER='user@feedback.com'" >> ./apps/api/.env
- name: Run integration tests (with opensearch)
run: |
npm run test:integration
- name: Setup integration test (without opensearch)
run: |
echo "OPENSEARCH_USE=false" >> ./apps/api/.env
- name: Run integration tests (without opensearch)
run: |
npm run test:integration
+45
View File
@@ -0,0 +1,45 @@
name: Publish Api Docs to GitHub Pages
on:
pull_request:
branches: [main]
jobs:
publish-api-docs:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: userfeedback
MYSQL_DATABASE: userfeedback
MYSQL_USER: userfeedback
MYSQL_PASSWORD: userfeedback
TZ: UTC
ports:
- 13306:3306
steps:
- name: Check out repository code
uses: actions/checkout@v5
- name: Build app and swagger docs
run: |
npm install -g corepack@latest
pnpm install --frozen-lockfile
pnpm build
cd apps/api
cp .env.example .env
npx ts-node -r tsconfig-paths/register src/scripts/build-swagger-docs.ts
- name: Run Redocly CLI
uses: fluximus-prime/redocly-cli-github-action@v1
with:
args: 'build-docs apps/api/swagger.json --output docs/index.html'
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs
+54
View File
@@ -0,0 +1,54 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# testing
coverage
# next.js
.next/
out/
build
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# turbo
!.turbo/.gitkeep
**/.turbo
# .env
.env*
!.env.example
!.env.build
volumes
.cache
# playwright
test-results/
playwright-report/
blob-report/
playwright/
# JetBrains
.idea
+2
View File
@@ -0,0 +1,2 @@
node-linker=hoisted
min-release-age=3
+1
View File
@@ -0,0 +1 @@
24.14.1
+8
View File
@@ -0,0 +1,8 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"yoavbls.pretty-ts-errors"
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript,typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true,
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
"eslint.runtime": "node",
"eslint.workingDirectories": [
{ "pattern": "apps/*/" },
{ "pattern": "packages/*/" },
{ "pattern": "tooling/*/" }
],
"prettier.ignorePath": ".gitignore",
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
"typescript.enablePromptUseWorkspaceTsdk": true,
"css.lint.unknownAtRules": "ignore",
"files.associations": {
"*.css": "tailwindcss"
},
"typescript.preferences.autoImportFileExcludePatterns": [
"@testing-library/react",
"react-i18next"
],
"[ignore]": {
"editor.defaultFormatter": "foxundermoon.shell-format"
}
}
+132
View File
@@ -0,0 +1,132 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
[dl_oss_dev@linecorp.com](mailto:dl_oss_dev@linecorp.com).
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
[https://www.contributor-covenant.org/version/2/0/code_of_conduct.html][v2.0].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
at [https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.0]: https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
+19
View File
@@ -0,0 +1,19 @@
# How to contribute to ABC User Feedback
First of all, thank you so much for taking your time to contribute! ABC User Feedback is not very different from any other open source projects. It will be fantastic if you help us by doing any of the following:
- File an issue in [the issue tracker](https://github.com/line/abc-user-feedback/issues)
to report bugs and propose new features and improvements.
- Ask a question using [the issue tracker](https://github.com/line/abc-user-feedback/issues).
- Contribute your work by sending [a pull request](https://github.com/line/abc-user-feedback/pulls).
- You should make your pull request base branch as `dev` not `main`. After merging to `dev` branch, we test the pull request in our separate environment and if there is no issue, it would be merged to `main` and released.
## Contributor license agreement
If you are sending a pull request and it's a non-trivial change beyond fixing
typos, please make sure to sign the [ICLA (Individual Contributor License Agreement)](https://cla-assistant.io/line/abc-user-feedback).
Please [contact us](mailto:dl_oss_dev@linecorp.com) if you need the CCLA (Corporate Contributor License Agreement).
## Code of conduct
We expect contributors to follow [our code of conduct](./CODE_OF_CONDUCT.md).
+17
View File
@@ -0,0 +1,17 @@
```mermaid
erDiagram
software_apps ||--o{ workspaces : "defines"
service_types ||--o{ workspaces : "defines"
users ||--o{ user_workspace_access : "has"
workspaces ||--o{ user_workspace_access : "accessed_by"
workspaces ||--o{ support_tickets : "contains"
users ||--o{ support_tickets : "requester"
support_tickets ||--o{ ticket_comments : "has"
support_tickets ||--o{ attachments : "has"
support_tickets ||--o{ request_approvals : "needs"
support_tickets ||--o{ asset_allocations : "requests"
support_tickets ||--o{ vehicle_schedules : "schedules"
support_tickets ||--o{ remote_support : "needs"
assets ||--o{ asset_allocations : "allocated"
assets ||--o{ vehicle_schedules : "scheduled"
workspaces ||--o{ faqs : "provides"
+211
View File
@@ -0,0 +1,211 @@
```mermaid
erDiagram
software_apps ||--o{ workspaces : defines
service_types ||--o{ workspaces : defines
workspaces ||--o{ user_workspace_access : accessed_by
workspaces ||--o{ support_tickets : contains
workspaces ||--o{ attachments : stores
workspaces ||--o{ faqs : provides
support_tickets ||--o{ ticket_comments : has
support_tickets ||--o{ attachments : has
support_tickets ||--o{ request_approvals : needs
support_tickets ||--o{ asset_allocations : requests
support_tickets ||--o{ vehicle_schedules : schedules
support_tickets ||--o{ remote_support : needs
support_tickets ||--o{ notification_logs : notifies
assets ||--o{ asset_allocations : allocated
assets ||--o{ vehicle_schedules : scheduled
support_status_codes ||--o{ support_tickets : statuses
support_category_codes ||--o{ support_tickets : categorizes
remote_support_status_codes ||--o{ remote_support : statuses
software_apps {
int id PK
string app_code UK
string app_name UK
string description
boolean is_active
datetime created_at
}
service_types {
int id PK
string service_code UK
string service_name UK
string description
boolean is_active
datetime created_at
}
workspaces {
int id PK
string workspace_type
int software_app_id FK
int service_type_id FK
string workspace_code UK
string workspace_name
boolean is_active
datetime created_at
}
user_workspace_access {
int id PK
string user_id
string tenant_id
int workspace_id FK
string workspace_role
boolean can_read
boolean can_write
boolean can_manage
boolean can_approve
string page_scope
datetime created_at
}
user_notification_profiles {
int id PK
string user_id
string tenant_id
string user_type
string phone_number
string naverworks_user_key
boolean sms_opt_in
datetime created_at
}
support_status_codes {
string code PK
string name
int sort_order
}
support_category_codes {
string code PK
string name
int sort_order
}
support_tickets {
int id PK
int workspace_id FK
string requester_id
string requester_tenant_id
string ticket_type
string title
string content
string category_code FK
string status_code FK
boolean is_secret
datetime requested_start_at
datetime requested_end_at
string priority
datetime created_at
datetime updated_at
}
ticket_comments {
int id PK
int ticket_id FK
string author_id
string author_tenant_id
string content
datetime created_at
}
attachments {
int id PK
string parent_type
int parent_id
int workspace_id FK
string file_name
string file_path
int file_size
datetime created_at
}
request_approvals {
int id PK
int ticket_id FK
string approver_id
string approver_tenant_id
string approval_status
string comment
datetime approved_at
datetime created_at
}
assets {
int id PK
string asset_code UK
string asset_name
string asset_type
int quantity
boolean is_active
string location
datetime created_at
}
asset_allocations {
int id PK
int ticket_id FK
int asset_id FK
string assignee_id
string assignee_tenant_id
string allocation_status
datetime loaned_at
datetime due_at
datetime returned_at
datetime created_at
}
vehicle_schedules {
int id PK
int ticket_id FK
int asset_id FK
datetime departure_at
datetime arrival_at
string destination
string driver_name
datetime created_at
}
remote_support_status_codes {
string code PK
string name
int sort_order
}
remote_support {
int id PK
int ticket_id FK
string status_code FK
string support_engineer_id
string support_engineer_tenant_id
datetime scheduled_time
datetime created_at
}
faqs {
int id PK
int workspace_id FK
string title
string content
boolean is_active
datetime created_at
}
notification_logs {
int id PK
int ticket_id FK
string recipient_id
string recipient_tenant_id
string channel
string target_address
string delivery_status
string fallback_channel
string error_message
datetime sent_at
}
user_notification_profiles ||..o{ notification_logs : receives
```
+217
View File
@@ -0,0 +1,217 @@
# ABC User Feedback Integration Guide
## Image Storage Integration
ABC User Feedback supports the integration of image storage solutions to handle images submitted as part of user feedback. We currently support AWS S3 and S3-compatible storage services.
### Uploading Images
There are two methods for uploading images associated with feedback:
1. **Multipart Upload API**: This method requires setting up the [image configuration](#S3-configuration). Once configured, you can use the multipart upload API to securely upload images directly to your storage service.
2. **Feedback Creation API with Image URLs**: Alternatively, users can submit feedback with image URLs. This method does not require the image configuration setup; however, the image URLs must come from the whitelisted domains.
**Note**: For detailed instructions on using these methods, please refer to the API documentation. You can see the documentation by accessing to `{API server host}/docs` or `{API server host}/docs/redoc`.
### S3 Configuration
To enable image uploads directly to the server, you must configure the image storage settings. The service uses the following configuration parameters and you can set them in the setting menu.
- `accessKeyId`: Your storage service access key ID.
- `secretAccessKey`: Your storage service secret access key.
- `endpoint`: The endpoint URL for the storage service.
- `region`: The region your storage service is located in.
- `bucket`: The name of the bucket where images will be stored.
- `enablePresignedUrlDownload`: Enable the setting to enhance download security by using the pre-signed URL feature supported by AWS S3.
Depending on your use case and the desired level of access, you may need to adjust the permissions of your S3 bucket. If your application requires that the images be publicly accessible, configure your S3 bucket's policy to allow public reads.
### Domain Whitelist
Users can specify a whitelist of domains for image URLs. This ensures that only images from trusted sources are accepted and managed by User Feedback API server.
**Note**: The domain whitelist is enforced at the time of posting feedback with images. This means that validation against the whitelist occurs only during the submission of new feedback. Once an image URL has been uploaded to the database and accepted, it will be accessible through the web admin interface regardless of its current status on the whitelist. It is important to ensure that image URLs are from trusted sources before they are uploaded, as subsequent changes to the whitelist will not retroactively affect previously stored image URLs.
## Webhook Feature
### Introduction to Webhooks
Webhooks in ABC User Feedback provide a powerful way to integrate with external services. They allow you to receive real-time notifications when specific events occur within the application, such as new feedback submissions or issue updates.
Furthermore, you can combine webhooks with ABC User Feedback API to make more powerful features such as translation, sentiment analysis or whatever you want. Just make a webhook listener and build your own script and send the result to ABC User Feedback by API.
### Setting Up Webhooks
To set up webhooks in ABC User Feedback:
1. Navigate to the project settings where you want to enable webhooks.
2. Add a new webhook by providing the URL endpoint that ABC User Feedback will send the data to when events occur.
3. Turn on the events you wish to subscribe to.
4. Save the webhook configuration.
Ensure that the endpoint you provide is secure and can accept POST requests with a JSON payload.
### Event Types and Request Bodies
ABC User Feedback's webhook supports the following event types, each with its own specific payload structure:
| No. | Title | Description |
|-----|--------------------|------------------------------------|
| 1 | FEEDBACK_CREATION | When the new Feedback is created. |
| 2 | ISSUE_ADDITION | When an Issue is added to a feedback. |
| 3 | ISSUE_CREATION | When the new Issue is created. |
| 4 | ISSUE_STATUS_CHANGE | When the Issue status is changed. |
#### 1. FEEDBACK_CREATION
This event is triggered when a new piece of feedback is created.
**Payload Structure:**
```json
{
"event": "FEEDBACK_CREATION",
"data": {
"feedback": {
"id": 1,
"createdAt": "2023-04-02T15:30:00Z",
"updatedAt": "2023-04-02T15:30:00Z",
"issues": [
{
"id": 1,
"createdAt": "2023-04-02T15:30:00Z",
"updatedAt": "2023-04-02T15:30:00Z",
"name": "issue name",
"description": "issue description",
"status": "INIT",
"externalIssueId": "123",
"feedbackCount": 1
}
]
},
"channel": {
"id": 1,
"name": "channel name"
},
"project": {
"id": 1,
"name": "project name"
}
}
}
```
#### 2. ISSUE_ADDITION
This event is triggered when an issue is added to an existing piece of feedback.
**Payload Structure:**
```json
{
"event": "ISSUE_ADDITION",
"data": {
"feedback": {
"id": 1,
"createdAt": "2023-04-02T15:30:00Z",
"updatedAt": "2023-04-02T15:30:00Z",
"issues": [
{
"id": 1,
"createdAt": "2023-04-02T15:30:00Z",
"updatedAt": "2023-04-02T15:30:00Z",
"name": "issue name",
"description": "issue description",
"status": "INIT",
"externalIssueId": "123",
"feedbackCount": 1
}
]
},
"channel": {
"id": 1,
"name": "channel name"
},
"project": {
"id": 1,
"name": "project name"
},
"addedIssue": {
"id": 1,
"createdAt": "2023-04-02T15:30:00Z",
"updatedAt": "2023-04-02T15:30:00Z",
"name": "issue name",
"description": "issue description",
"status": "INIT",
"externalIssueId": "123",
"feedbackCount": 1
}
}
}
```
#### 3. ISSUE_CREATION
This event is triggered when a new issue is created within a project.
**Payload Structure:**
```json
{
"event": "ISSUE_CREATION",
"data": {
"issue": {
"id": 1,
"createdAt": "2023-04-02T15:30:00Z",
"updatedAt": "2023-04-02T15:30:00Z",
"name": "issue name",
"description": "issue description",
"status": "INIT",
"externalIssueId": "123",
"feedbackCount": 1
},
"project": {
"id": 1,
"name": "project name"
}
}
}
```
#### 4. ISSUE_STATUS_CHANGE
This event is triggered when the status of an issue is updated.
**Payload Structure:**
```json
{
"event": "ISSUE_STATUS_CHANGE",
"data": {
"issue": {
"id": 1,
"createdAt": "2023-04-02T15:30:00Z",
"updatedAt": "2023-04-02T15:30:00Z",
"name": "issue name",
"description": "issue description",
"status": "ON_REVIEW",
"externalIssueId": "123",
"feedbackCount": 1
},
"project": {
"id": 1,
"name": "project name"
},
"previousStatus": "INIT"
}
}
```
### Handling Webhooks
Upon receiving a webhook payload, your endpoint should:
Parse the JSON payload.
Take appropriate action based on the event type and data received.
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2021 LINE Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+75
View File
@@ -0,0 +1,75 @@
# ABC User Feedback
ABC User Feedback is a standalone web application designed to manage Voice of Customer (VoC) data. It enables you to efficiently gather and categorize customer feedback. The application is currently utilized in services with a reach of 10 million MAU.
![main image](./assets/main.png)
## ✨ Features
- **Feedback Tag**: Categorize feedback by topic with customizable tags
- **Kanban Mode**: Visualize and organize issue groups efficiently
- **Issue Tracker**: Simple issue tracking with status indicators and external ticket linking
- **Single Sign-on**: Enterprise-level OAuth authentication
- **Role Management**: Role Based Access Control (RBAC)
- **Dashboard**: Statistical visualization for feedback and issues
- **🤖 AI Field**: AI-powered feedback analysis (summarization, translation, sentiment analysis)
- **🤖 AI Issue Recommendation**: Intelligent issue recommendations based on feedback
## 🚀 Quick Start
Get started with ABC User Feedback in minutes using our CLI tool:
```bash
npx auf-cli init # initialize infrastructure
npx auf-cli start # start app
```
That's it! The application will be running with all required infrastructure.
**For detailed installation options**, see our [Installation Guide](https://docs.abc-user-feedback.com/en/developer-guide/installation/docker-hub-images).
## 📚 Documentation
Complete documentation is available at **[https://docs.abc-user-feedback.com/en/](https://docs.abc-user-feedback.com/en/)**
- [Getting Started](https://docs.abc-user-feedback.com/en/user-guide/getting-started) - User guide and tutorials
- [Installation](https://docs.abc-user-feedback.com/en/developer-guide/installation) - Detailed setup instructions
- [API Integration](https://docs.abc-user-feedback.com/en/developer-guide/api-integration) - REST API documentation
- [Configuration](https://docs.abc-user-feedback.com/en/developer-guide/installation/configuration) - Environment variables and settings
## 🐳 Docker Images
Pre-built Docker images are available on Docker Hub:
- **Web Frontend**: `docker pull line/abc-user-feedback-web`
- **API Backend**: `docker pull line/abc-user-feedback-api`
See [Docker Hub Images Guide](https://docs.abc-user-feedback.com/en/developer-guide/installation/docker-hub-images) for usage details.
## 🛠️ Development
For local development setup:
```bash
git clone https://github.com/line/abc-user-feedback
cd abc-user-feedback
pnpm install
pnpm build
pnpm dev
```
See the [Manual Setup](https://docs.abc-user-feedback.com/en/developer-guide/installation/manual-setup) for complete development instructions.
## 🤝 Contributing
We welcome contributions! Please see our [Contributing Guidelines](./CONTRIBUTING.md).
## 📄 License
Copyright 2025 LY Corporation
Licensed under the Apache License, Version 2.0. See [LICENSE](./LICENSE) for details.
---
For questions and support, please visit our [documentation](https://docs.abc-user-feedback.com/en/).
+42
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
*.hbs
+136
View File
@@ -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/).
+33
View File
@@ -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',
});
}
+66
View File
@@ -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();
});
});
+19
View File
@@ -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'],
};
+33
View File
@@ -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: [],
};
},
}));
+12
View File
@@ -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"
}
}
+121
View File
@@ -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:"
}
}
+152
View File
@@ -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;
+17
View File
@@ -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));
}
+19
View File
@@ -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();
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { CommonEntity } from './common.entity';
@@ -0,0 +1,21 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum AIPromptStatusEnum {
success = 'success',
error = 'error',
loading = 'loading',
}
@@ -0,0 +1,20 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum AIProvidersEnum {
OPEN_AI = 'OPEN_AI',
GEMINI = 'GEMINI',
}
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum EventStatusEnum {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
}
@@ -0,0 +1,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',
}
+24
View File
@@ -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,
});
}
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { HttpExceptionFilter } from './http-exception.filter';
+58
View File
@@ -0,0 +1,58 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
interface ToNumberOptions {
default?: number;
min?: number;
max?: number;
}
export function toLowerCase(value: string): string {
return value.toLowerCase();
}
export function trim(value: string): string {
return value.trim();
}
export function toDate(value: string): Date {
return new Date(value);
}
export function toBoolean(value: string): boolean {
value = value.toLowerCase();
return value === 'true' || value === '1' ? true : false;
}
export function toNumber(value: string, opts: ToNumberOptions = {}): number {
let newValue: number = Number.parseInt(value || String(opts.default), 10);
if (Number.isNaN(newValue)) {
newValue = opts.default ?? 0;
}
if (opts.min) {
if (newValue < opts.min) {
newValue = opts.min;
}
if (opts.max && newValue > opts.max) {
newValue = opts.max;
}
}
return newValue;
}
@@ -0,0 +1,43 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { paginate } from 'nestjs-typeorm-paginate';
import type {
IPaginationMeta,
IPaginationOptions,
} from 'nestjs-typeorm-paginate';
import type { FindManyOptions, SelectQueryBuilder } from 'typeorm';
export async function paginateHelper(
queryBuilder: SelectQueryBuilder<any>,
findOptions: FindManyOptions<any>,
options: IPaginationOptions,
) {
const totalItems = await queryBuilder
.clone()
.setFindOptions(findOptions)
.getCount();
return await paginate(queryBuilder.setFindOptions(findOptions), {
...options,
countQueries: false,
metaTransformer: (meta: IPaginationMeta): IPaginationMeta => {
return {
...meta,
totalItems,
totalPages: Math.ceil(totalItems / meta.itemsPerPage),
};
},
});
}
@@ -0,0 +1,20 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export class CreateDataDto {
id?: string;
index: string;
data: Record<string, any>;
}
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export class CreateIndexDto {
index: string;
}
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export class DeleteBulkDataDto {
ids: number[];
index: string;
}
@@ -0,0 +1,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { PaginationDto } from '@/common/dtos';
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
export class GetDataDto extends PaginationDto {
index: string;
query: OsQueryDto;
sort: string[];
}
@@ -0,0 +1,22 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { CreateIndexDto } from './create-index.dto';
export { PutMappingsDto } from './put-mappings.dto';
export { CreateDataDto } from './create-data.dto';
export { GetDataDto } from './get-data.dto';
export { UpdateDataDto } from './update-data.dto';
export { DeleteBulkDataDto } from './delete-bulk-data.dto';
export { ScrollDto } from './scroll.dto';
@@ -0,0 +1,22 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { Property } from '@opensearch-project/opensearch/api/_types/_common.mapping';
export class PutMappingsDto {
index: string;
mappings: Record<string, Property>;
}
@@ -0,0 +1,24 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
export class ScrollDto {
index: string;
query: OsQueryDto;
sort: string[];
size: number;
scrollId: string | null;
}
@@ -0,0 +1,20 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export class UpdateDataDto {
id: string;
index: string;
data: Record<string, any>;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { OpensearchRepository } from './opensearch.repository';
@@ -0,0 +1,27 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { BadRequestException } from '@nestjs/common';
import { ErrorCode } from '@ufb/shared';
export class LargeWindowException extends BadRequestException {
constructor(message: string) {
super({
code: ErrorCode.Opensearch.LargeWindow,
message,
});
}
}
@@ -0,0 +1,697 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { faker } from '@faker-js/faker';
import {
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { Client } from '@opensearch-project/opensearch';
import type { TextProperty } from '@opensearch-project/opensearch/api/_types/_common.mapping';
import { getMockProvider } from '@/test-utils/util-functions';
import { CreateDataDto, PutMappingsDto } from './dtos';
import { OpensearchRepository } from './opensearch.repository';
const MockClient = {
indices: {
create: jest.fn(),
putAlias: jest.fn(),
exists: jest.fn(),
putMapping: jest.fn(),
getMapping: jest.fn(),
delete: jest.fn(),
},
index: jest.fn(),
search: jest.fn(),
scroll: jest.fn(),
update: jest.fn(),
deleteByQuery: jest.fn(),
count: jest.fn(),
};
const OpensearchRepositoryProviders = [
OpensearchRepository,
getMockProvider('OPENSEARCH_CLIENT', MockClient),
];
const COMPLICATE_JSON = {
KEY1: 'VALUE1',
KEY2: 'VALUE2',
};
const MAPPING_JSON = {
KEY1: {
type: 'text',
} as TextProperty,
};
describe('Opensearch Repository Test suite', () => {
let osRepo: OpensearchRepository;
let osClient: Client;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: OpensearchRepositoryProviders,
}).compile();
osRepo = module.get(OpensearchRepository);
osClient = module.get('OPENSEARCH_CLIENT');
});
describe('create index', () => {
it('positive case', async () => {
const index = faker.number.int().toString();
const indexName = 'channel_' + index;
jest.spyOn(osClient.indices, 'create');
jest.spyOn(osClient.indices, 'putAlias');
await osRepo.createIndex({ index });
expect(osClient.indices.create).toHaveBeenCalledTimes(1);
expect(osClient.indices.create).toHaveBeenCalledWith({
index: indexName,
body: {
settings: {
index: { max_ngram_diff: 1 },
analysis: {
analyzer: {
ngram_analyzer: {
filter: ['lowercase', 'asciifolding', 'cjk_width'],
tokenizer: 'ngram_tokenizer',
type: 'custom',
},
},
tokenizer: {
ngram_tokenizer: {
type: 'ngram',
min_gram: 1,
max_gram: 2,
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
},
},
},
},
},
});
expect(osClient.indices.putAlias).toHaveBeenCalledTimes(1);
expect(osClient.indices.putAlias).toHaveBeenCalledWith({
index: indexName,
name: index,
});
});
it('creating index handles errors', async () => {
const index = faker.number.int().toString();
const error = new Error('Index creation failed');
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
await expect(osRepo.createIndex({ index })).rejects.toThrow(
'Index creation failed',
);
});
it('creating index handles OpenSearch specific errors', async () => {
const index = faker.number.int().toString();
const error = {
meta: {
body: {
error: {
type: 'resource_already_exists_exception',
reason: 'index already exists',
},
},
},
};
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
await expect(osRepo.createIndex({ index })).rejects.toEqual(error);
});
});
describe('putMappings', () => {
it('putting mappings succeeds with an existent index', async () => {
const dto = new PutMappingsDto();
dto.index = faker.number.int().toString();
dto.mappings = MAPPING_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 200 } as never);
jest.spyOn(osClient.indices, 'putMapping');
await osRepo.putMappings(dto);
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
expect(osClient.indices.putMapping).toHaveBeenCalledTimes(1);
expect(osClient.indices.putMapping).toHaveBeenCalledWith({
index: dto.index,
body: { properties: dto.mappings },
});
});
it('putting mappings fails with a nonexistent index', async () => {
const dto = new PutMappingsDto();
dto.index = faker.number.int().toString();
dto.mappings = MAPPING_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 404 } as never);
jest.spyOn(osClient.indices, 'putMapping');
await expect(osRepo.putMappings(dto)).rejects.toThrow(
new NotFoundException('index is not found'),
);
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
expect(osClient.indices.putMapping).not.toHaveBeenCalled();
});
it('putting mappings handles OpenSearch errors', async () => {
const dto = new PutMappingsDto();
dto.index = faker.number.int().toString();
dto.mappings = MAPPING_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 200 } as never);
const error = {
meta: {
body: {
error: {
type: 'illegal_argument_exception',
reason: 'mapping update failed',
},
},
},
};
jest
.spyOn(osClient.indices, 'putMapping')
.mockRejectedValue(error as never);
await expect(osRepo.putMappings(dto)).rejects.toEqual(error);
});
});
describe('createData', () => {
it('creating data succeeds with valid inputs', async () => {
const index = faker.number.int().toString();
const id = faker.number.int().toString();
const dto = new CreateDataDto();
dto.id = id;
dto.index = index;
dto.data = COMPLICATE_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 200 } as never);
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
body: {
['channel_' + index]: {
mappings: {
properties: dto.data,
},
},
},
} as never);
jest.spyOn(osClient, 'index').mockResolvedValue({
body: {
_id: dto.id,
},
} as never);
const response = await osRepo.createData(dto);
expect(response.id).toEqual(dto.id);
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
expect(osClient.index).toHaveBeenCalledTimes(1);
expect(osClient.index).toHaveBeenCalledWith({
id: dto.id,
index: 'channel_' + index,
body: dto.data,
refresh: true,
});
});
it('creating data fails with an invalid index', async () => {
const invalidIndex = faker.number.int().toString();
const id = faker.number.int().toString();
const dto = new CreateDataDto();
dto.id = id;
dto.index = invalidIndex;
dto.data = COMPLICATE_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ body: false } as never);
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
body: {
['channel_' + faker.number.int().toString()]: {
mappings: {
properties: dto.data,
},
},
},
} as never);
jest.spyOn(osClient, 'index').mockResolvedValue({
body: {
_id: dto.id,
},
} as never);
await expect(osRepo.createData(dto)).rejects.toThrow(
new NotFoundException('index is not found'),
);
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
expect(osClient.indices.getMapping).not.toHaveBeenCalled();
expect(osClient.index).not.toHaveBeenCalled();
});
it('creating data fails with invalid data', async () => {
const index = faker.number.int().toString();
const id = faker.number.int().toString();
const data = COMPLICATE_JSON;
const dto = new CreateDataDto();
dto.id = id;
dto.index = index;
dto.data = {
...data,
invalidKey: 'invalidValue',
};
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 200 } as never);
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
body: {
['channel_' + index]: {
mappings: {
properties: data,
},
},
},
} as never);
jest.spyOn(osClient, 'index').mockResolvedValue({
body: {
_id: dto.id,
},
} as never);
await expect(osRepo.createData(dto)).rejects.toThrow(
new InternalServerErrorException('error!!!'),
);
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
expect(osClient.index).not.toHaveBeenCalled();
});
});
describe('getData', () => {
it('getting data succeeds with valid inputs', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const sort = ['_id:desc'];
const limit = 10;
const page = 1;
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: {
hits: [
{ _source: { KEY1: 'VALUE1' } },
{ _source: { KEY2: 'VALUE2' } },
],
total: 2,
},
},
} as never);
const result = await osRepo.getData({ index, query, sort, limit, page });
expect(result.items).toHaveLength(2);
expect(result.total).toBe(2);
expect(osClient.search).toHaveBeenCalledWith({
index,
from: 0,
size: limit,
sort,
body: { query },
});
});
it('getting data with empty sort adds default sort', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const sort: string[] = [];
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: {
hits: [],
total: 0,
},
},
} as never);
await osRepo.getData({ index, query, sort, page: 1, limit: 100 });
expect(osClient.search).toHaveBeenCalledWith({
index,
from: 0,
size: 100,
sort: ['_id:desc'],
body: { query },
});
});
it('getting data handles large window exception', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const error = new Error('Result window is too large');
error.name = 'OpenSearchClientError';
jest.spyOn(osClient, 'search').mockRejectedValue(error as never);
await expect(
osRepo.getData({ index, query, sort: [], page: 1, limit: 100 }),
).rejects.toThrow('Result window is too large');
});
it('getting data handles total as object', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: {
hits: [],
total: { value: 100, relation: 'eq' },
},
},
} as never);
const result = await osRepo.getData({
index,
query,
sort: [],
page: 1,
limit: 100,
});
expect(result.total).toBe(100);
});
});
describe('scroll', () => {
it('scrolling with scrollId succeeds', async () => {
const scrollId = faker.string.alphanumeric(32);
const mockData = [{ KEY1: 'VALUE1' }, { KEY2: 'VALUE2' }];
jest.spyOn(osClient, 'scroll').mockResolvedValue({
body: {
hits: {
hits: mockData.map((data) => ({ _source: data })),
},
_scroll_id: scrollId,
},
} as never);
const result = await osRepo.scroll({
scrollId,
index: '',
size: 10,
query: { bool: { must: [{ term: { status: 'active' } }] } },
sort: [],
});
expect(result.data).toEqual(mockData);
expect(result.scrollId).toEqual(scrollId);
expect(osClient.scroll).toHaveBeenCalledWith({
scroll_id: scrollId,
scroll: '1m',
});
});
it('scrolling without scrollId performs initial search', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const sort = ['_id:desc'];
const size = 10;
const mockData = [{ KEY1: 'VALUE1' }];
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: {
hits: mockData.map((data) => ({ _source: data })),
},
_scroll_id: 'new_scroll_id',
},
} as never);
const result = await osRepo.scroll({
index,
query,
sort,
size,
scrollId: null,
});
expect(result.data).toEqual(mockData);
expect(result.scrollId).toEqual('new_scroll_id');
expect(osClient.search).toHaveBeenCalledWith({
index,
size,
sort,
body: { query },
scroll: '1m',
});
});
it('scrolling with empty sort adds default sort', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const sort: string[] = [];
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: { hits: [] },
_scroll_id: 'scroll_id',
},
} as never);
await osRepo.scroll({ index, query, sort, size: 10, scrollId: null });
expect(osClient.search).toHaveBeenCalledWith({
index,
size: 10,
sort: ['_id:desc'],
body: { query },
scroll: '1m',
});
});
});
describe('updateData', () => {
it('updating data succeeds with valid inputs', async () => {
const index = faker.number.int().toString();
const id = faker.number.int().toString();
const updateData = { KEY1: 'UPDATED_VALUE' };
jest.spyOn(osClient, 'update').mockResolvedValue({
body: {
_id: id,
result: 'updated',
},
} as never);
await osRepo.updateData({ index, id, data: updateData });
expect(osClient.update).toHaveBeenCalledWith({
index,
id,
body: {
doc: updateData,
},
refresh: true,
retry_on_conflict: 5,
});
});
it('updating data handles errors', async () => {
const index = faker.number.int().toString();
const id = faker.number.int().toString();
const updateData = { KEY1: 'UPDATED_VALUE' };
const error = new Error('Update failed');
jest.spyOn(osClient, 'update').mockRejectedValue(error as never);
await expect(
osRepo.updateData({ index, id, data: updateData }),
).rejects.toThrow('Update failed');
});
});
describe('deleteBulkData', () => {
it('deleting bulk data succeeds with valid ids', async () => {
const index = faker.number.int().toString();
const ids = [faker.number.int(), faker.number.int()];
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
body: {
deleted: ids.length,
},
} as never);
await osRepo.deleteBulkData({ index, ids });
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
index,
body: { query: { terms: { _id: ids } } },
refresh: true,
});
});
it('deleting bulk data with empty ids array', async () => {
const index = faker.number.int().toString();
const ids: number[] = [];
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
body: {
deleted: 0,
},
} as never);
await osRepo.deleteBulkData({ index, ids });
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
index,
body: { query: { terms: { _id: ids } } },
refresh: true,
});
});
});
describe('deleteIndex', () => {
it('deleting index succeeds with valid index', async () => {
const index = faker.number.int().toString();
const indexName = 'channel_' + index;
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
body: {
acknowledged: true,
},
} as never);
await osRepo.deleteIndex(index);
expect(osClient.indices.delete).toHaveBeenCalledWith({
index: indexName,
});
});
it('deleting index handles errors', async () => {
const index = faker.number.int().toString();
const error = new Error('Delete failed');
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
await expect(osRepo.deleteIndex(index)).rejects.toThrow('Delete failed');
});
});
describe('getTotal', () => {
it('getting total count succeeds with valid query', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
jest.spyOn(osClient, 'count').mockResolvedValue({
body: {
count: 100,
},
} as never);
const result = await osRepo.getTotal(index, query);
expect(result).toBe(100);
expect(osClient.count).toHaveBeenCalledWith({
index,
body: { query },
});
});
it('getting total count with complex query', async () => {
const index = faker.number.int().toString();
const query = {
bool: {
must: [
{ term: { status: 'active' } },
{ range: { created_at: { gte: '2023-01-01' } } },
],
},
};
jest.spyOn(osClient, 'count').mockResolvedValue({
body: {
count: 50,
},
} as never);
const result = await osRepo.getTotal(index, query);
expect(result).toBe(50);
expect(osClient.count).toHaveBeenCalledWith({
index,
body: { query },
});
});
it('getting total count handles errors', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const error = new Error('Count failed');
jest.spyOn(osClient, 'count').mockRejectedValue(error as never);
await expect(osRepo.getTotal(index, query)).rejects.toThrow(
'Count failed',
);
});
});
describe('deleteAllIndexes', () => {
it('deleting all indexes succeeds', async () => {
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
body: {
acknowledged: true,
},
} as never);
await osRepo.deleteAllIndexes();
expect(osClient.indices.delete).toHaveBeenCalledWith({
index: '_all',
});
});
it('deleting all indexes handles errors', async () => {
const error = new Error('Delete all failed');
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
await expect(osRepo.deleteAllIndexes()).rejects.toThrow(
'Delete all failed',
);
});
});
});
@@ -0,0 +1,263 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Client, errors } from '@opensearch-project/opensearch';
import { Indices_PutMapping_Response } from '@opensearch-project/opensearch/api';
import type {
CreateDataDto,
CreateIndexDto,
DeleteBulkDataDto,
GetDataDto,
PutMappingsDto,
ScrollDto,
UpdateDataDto,
} from './dtos';
import { LargeWindowException } from './large-window.exception';
@Injectable()
export class OpensearchRepository {
private logger = new Logger(OpensearchRepository.name);
private opensearchClient: Client;
constructor(@Inject('OPENSEARCH_CLIENT') opensearchClient: Client) {
this.opensearchClient = opensearchClient;
}
async createIndex({ index }: CreateIndexDto) {
const indexName = 'channel_' + index;
try {
const response = await this.opensearchClient.indices.create({
index: indexName,
body: {
settings: {
index: { max_ngram_diff: 1 },
analysis: {
analyzer: {
ngram_analyzer: {
type: 'custom',
filter: ['lowercase', 'asciifolding', 'cjk_width'],
tokenizer: 'ngram_tokenizer',
},
},
tokenizer: {
ngram_tokenizer: {
type: 'ngram',
min_gram: 1,
max_gram: 2,
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
},
},
},
},
},
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (response) {
this.logger.log(
`Index created successfully: ${JSON.stringify(response.body, null, 2)}`,
);
}
} catch (error) {
this.logger.log(`Error creating index: ${error}`);
if (error?.meta?.body) {
this.logger.log(
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
);
}
throw error;
}
await this.opensearchClient.indices.putAlias({
index: indexName,
name: index,
});
}
async putMappings({ index, mappings }: PutMappingsDto) {
const { statusCode } = await this.opensearchClient.indices.exists({
index,
});
if (statusCode !== 200) throw new NotFoundException('index is not found');
let response: Indices_PutMapping_Response;
try {
response = await this.opensearchClient.indices.putMapping({
index,
body: { properties: mappings },
});
} catch (error) {
this.logger.log(`Error put mapping: ${error}`);
if (error?.meta?.body) {
this.logger.log(
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
);
}
throw error;
}
return response;
}
async createData({ id, index, data }: CreateDataDto) {
const indexName = 'channel_' + index;
const existence = await this.opensearchClient.indices.exists({
index: indexName,
});
if (existence.statusCode !== 200)
throw new NotFoundException('index is not found');
const response = await this.opensearchClient.indices.getMapping({
index: indexName,
});
const mappingKeys = Object.keys(
response.body[indexName].mappings.properties as object,
);
const dataKeys = Object.keys(data);
if (!dataKeys.every((v) => mappingKeys.includes(v))) {
throw new InternalServerErrorException('error!!!');
}
const { body } = await this.opensearchClient.index({
id,
index: indexName,
body: data,
refresh: true,
});
return { id: body._id as unknown as number };
}
async getData(dto: GetDataDto) {
const { index, limit = 100, page = 1, query, sort } = dto;
if (sort.length === 0) {
sort.push('_id:desc');
}
try {
const { body } = await this.opensearchClient.search({
index,
from: (page - 1) * limit,
size: limit,
sort,
body: { query },
});
return {
items: body.hits.hits.map((v) => ({
...v._source,
})) as Record<string, any>[],
total:
typeof body.hits.total === 'number' ?
body.hits.total
: (body.hits.total?.value ?? 0),
};
} catch (error) {
if (error instanceof errors.OpenSearchClientError) {
if (error.message.includes('Result window is too large')) {
throw new LargeWindowException(error.message);
}
}
throw error;
}
}
async scroll(dto: ScrollDto) {
const { index, size, scrollId, query, sort } = dto;
if (sort.length === 0) sort.push('_id:desc');
if (scrollId) {
const { body } = await this.opensearchClient.scroll({
scroll_id: scrollId,
scroll: '1m',
});
return this.convertToScrollData(body);
}
const { body } = await this.opensearchClient.search({
index,
size,
sort,
body: { query },
scroll: '1m',
});
return this.convertToScrollData(body);
}
private convertToScrollData(body) {
return {
data: body.hits.hits.map((v) => ({
...v._source,
})) as Record<string, any>[],
scrollId: body._scroll_id,
};
}
async updateData({ id, index, data }: UpdateDataDto) {
try {
await this.opensearchClient.update({
id,
index,
body: { doc: data },
refresh: true,
retry_on_conflict: 5,
});
} catch (error) {
this.logger.error(`Error updating data: ${error}`);
if (error?.meta?.body) {
this.logger.error(
`OpenSearch error details: ${JSON.stringify(error.meta.body, null, 2)}`,
);
}
throw error;
}
}
async deleteBulkData({ ids, index }: DeleteBulkDataDto) {
await this.opensearchClient.deleteByQuery({
index,
body: { query: { terms: { _id: ids } } },
refresh: true,
});
}
async deleteIndex(index: string) {
await this.opensearchClient.indices.delete({ index: 'channel_' + index });
}
async deleteAllIndexes() {
await this.opensearchClient.indices.delete({ index: '_all' });
}
async getTotal(index: string, query: object): Promise<number> {
const { body } = await this.opensearchClient.count({
index,
body: { query },
});
return body.count;
}
}
@@ -0,0 +1,42 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { ValidationArguments, ValidationOptions } from 'class-validator';
import { registerDecorator } from 'class-validator';
export const ArrayDistinct = (
property?: string,
validationOptions?: ValidationOptions,
) => {
return (object: object, propertyName: string) => {
registerDecorator({
name: 'ArrayDistinct',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: unknown): boolean {
return Array.isArray(value) ?
[...new Set(value)].length === value.length
: false;
},
defaultMessage(args: ValidationArguments): string {
return `must not contains duplicate entry for ${args.constraints[0]}`;
},
},
});
};
};
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { ArrayDistinct } from './array-distinct';
@@ -0,0 +1,61 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { IsNotEmpty, validate } from 'class-validator';
import { TokenValidator } from './token-validator';
class TokenDto {
@IsNotEmpty()
@TokenValidator({ message: 'Invalid token format' })
token: string;
}
describe('TokenValidator', () => {
it('should validate a correct token', async () => {
const dto = new TokenDto();
dto.token = 'validToken123456';
const errors = await validate(dto);
expect(errors.length).toBe(0);
});
it('should invalidate a token with invalid characters', async () => {
const dto = new TokenDto();
dto.token = 'invalidToken$123';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
});
it('should invalidate a token that is too short', async () => {
const dto = new TokenDto();
dto.token = 'short';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('TokenValidatorConstraint');
});
it('should invalidate an empty token', async () => {
const dto = new TokenDto();
dto.token = '';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
});
});
@@ -0,0 +1,48 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: false })
export class TokenValidatorConstraint implements ValidatorConstraintInterface {
validate(token: string | null) {
const regex = /^[a-zA-Z0-9._-]+$/;
return (
!token ||
(typeof token === 'string' && regex.test(token) && token.length >= 16)
);
}
defaultMessage() {
return 'Token must be at least 16 characters long and contain only alphanumeric characters, dots, hyphens, and underscores.';
}
}
export function TokenValidator(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: TokenValidatorConstraint,
});
};
}
+47
View File
@@ -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(),
}));
+29
View File
@@ -0,0 +1,29 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { registerAs } from '@nestjs/config';
import Joi from 'joi';
export const jwtConfigSchema = Joi.object({
JWT_SECRET: Joi.string().required(),
ACCESS_TOKEN_EXPIRED_TIME: Joi.string().default('10m'),
REFRESH_TOKEN_EXPIRED_TIME: Joi.string().default('1h'),
});
export const jwtConfig = registerAs('jwt', () => ({
secret: process.env.JWT_SECRET,
accessTokenExpiredTime: process.env.ACCESS_TOKEN_EXPIRED_TIME,
refreshTokenExpiredTime: process.env.REFRESH_TOKEN_EXPIRED_TIME,
}));
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { OpensearchConfigModule } from './opensearch-config/opensearch-config.module';
export { MailerConfigModule } from './mailer-config/mailer-config.module';
export { TypeOrmConfigModule } from './typeorm-config/typeorm-config.module';
@@ -0,0 +1,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'
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class='fw-700 text-center text-lg'
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
align='center'
>Sign up to UserFeedback</div>
<table
class='s-3 w-full'
style='width: 100%;'
width='100%'
>
<tbody>
<tr>
<td
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
align='left'
width='100%'
height='12'
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class='fw-400 text-center text-sm'
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
align='center'
>Please sign up using the button below.
<br />
This link will expire after 24 hours or if it is used
once.</div>
<table
class='s-6 w-full'
style='width: 100%;'
width='100%'
>
<tbody>
<tr>
<td
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
align='left'
width='100%'
height='24'
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class='ax-center btn btn-black w-56'
align='center'
style='border-radius: 6px; border-collapse: separate !important; width: 224px; margin: 0 auto;'
width='224'
>
<tbody>
<tr>
<td
style='line-height: 24px; font-size: 16px; border-radius: 6px; width: 224px; margin: 0;'
align='center'
bgcolor='#000000'
width='224'
>
<a
href='{{link}}'
style='color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #000000; padding: 8px 12px; border: 1px solid #000000;'
>Sign Up</a>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
<tr>
<td
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
align='left'
width='100%'
>
<div
class='fw-400 text-secondary text-center text-xs'
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
align='center'
>This is an automated message. Please do not reply to this
email.</div>
</td>
</tr>
<tr>
<td
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
align='left'
width='100%'
>
<table class='s-6' style='width: 100%;' width='100%'>
<tbody>
<tr>
<td
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
align='left'
valign='top'
>
<table>
<tbody>
<tr>
<td>
<img
width='16'
height='16'
src='{{baseUrl}}/assets/mailing/logo.svg'
alt='Logo'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
/>
</td>
<td>
<img
width='116'
height='18'
src='{{baseUrl}}/assets/mailing/title-ufb.png'
alt='UserFeedback'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
/>
</td>
</tr>
</tbody>
</table>
</td>
<td align='left' valign='center'>
<table align='center' style='margin: 0 auto;'>
<tbody>
<tr>
<td
style='padding-right: 12px; margin: 0;'
align='left'
valign='top'
>
<img
width='16'
height='16'
src='{{baseUrl}}/assets/mailing/globe-fill.png'
alt='Website'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
/>
</td>
<td
style='padding-right: 12px; margin: 0;'
align='left'
valign='top'
>
<a
href='https://github.com/line/abc-user-feedback'
style='color: #0d6efd;'
>
<img
width='16'
height='16'
src='{{baseUrl}}/assets/mailing/github-mark.png'
alt='GitHub'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
/>
</a>
</td>
<td
style='padding-right: 0; margin: 0;'
align='left'
valign='top'
>
<a
href='mailto:dl_abc_userfeedback@linecorp.com'
style='color: #0d6efd;'
>
<img
width='16'
height='16'
src='{{baseUrl}}/assets/mailing/mail-fill.png'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
/>
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>
@@ -0,0 +1,306 @@
<html>
<head>
<!-- Compiled with Bootstrap Email version: 1.3.1 --><meta
http-equiv='x-ua-compatible'
content='ie=edge'
/>
<meta name='x-apple-disable-message-reformatting' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<meta
name='format-detection'
content='telephone=no, date=no, address=no, email=no'
/>
<meta http-equiv='Content-Type' content='text/html; charset=utf-8' />
<style type='text/css'>
body,table,td{font-family:Helvetica,Arial,sans-serif
!important}.ExternalClass{width:100%}.ExternalClass,.ExternalClass
p,.ExternalClass span,.ExternalClass font,.ExternalClass td,.ExternalClass
div{line-height:150%}a{text-decoration:none}*{color:inherit}a[x-apple-data-detectors],u+#body
a,#MessageViewBody
a{color:inherit;text-decoration:none;font-size:inherit;font-family:inherit;font-weight:inherit;line-height:inherit}img{-ms-interpolation-mode:bicubic}table:not([class^=s-]){font-family:Helvetica,Arial,sans-serif;mso-table-lspace:0pt;mso-table-rspace:0pt;border-spacing:0px;border-collapse:collapse}table:not([class^=s-])
td{border-spacing:0px;border-collapse:collapse}@media screen and
(max-width: 600px){.gap-3.row,.gap-x-3.row{margin-right:-12px
!important}.gap-3.row>table>tbody>tr>td,.gap-x-3.row>table>tbody>tr>td{padding-right:12px
!important}.gap-3.row,.gap-y-3.row{margin-bottom:-12px
!important}.gap-3.row>table>tbody>tr>td,.gap-y-3.row>table>tbody>tr>td{padding-bottom:12px
!important}.gap-8.row,.gap-x-8.row{margin-right:-32px
!important}.gap-8.row>table>tbody>tr>td,.gap-x-8.row>table>tbody>tr>td{padding-right:32px
!important}.gap-8.row,.gap-y-8.row{margin-bottom:-32px
!important}.gap-8.row>table>tbody>tr>td,.gap-y-8.row>table>tbody>tr>td{padding-bottom:32px
!important}table.gap-3.stack-x>tbody>tr>td{padding-right:12px
!important}table.gap-3.stack-y>tbody>tr>td{padding-bottom:12px
!important}table.gap-8.stack-x>tbody>tr>td{padding-right:32px
!important}table.gap-8.stack-y>tbody>tr>td{padding-bottom:32px
!important}.w-full,.w-full>tbody>tr>td{width:100%
!important}.w-56,.w-56>tbody>tr>td{width:224px
!important}.p-4:not(table),.p-4:not(.btn)>tbody>tr>td,.p-4.btn td
a{padding:16px !important}*[class*=s-lg-]>tbody>tr>td{font-size:0
!important;line-height:0 !important;height:0
!important}.s-3>tbody>tr>td{font-size:12px !important;line-height:12px
!important;height:12px !important}.s-6>tbody>tr>td{font-size:24px
!important;line-height:24px !important;height:24px
!important}.s-10>tbody>tr>td{font-size:40px !important;line-height:40px
!important;height:40px !important}}
</style>
</head>
<body
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
bgcolor='#ffffff'
>
<table
valign='top'
style='outline: 0; width: 100%; min-width: 100%; height: 100%; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; font-family: Helvetica, Arial, sans-serif; line-height: 24px; font-weight: normal; font-size: 16px; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; box-sizing: border-box; color: #000000; margin: 0; padding: 0; border-width: 0;'
>
<tbody>
<tr>
<td valign='top' align='center'>
<table
align='center'
style='width: 100%; max-width: 600px; margin: 0 auto;'
>
<tbody>
<tr style='height: 32px;'>
</tr>
<tr>
<td
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
align='left'
width='100%'
>
<div>
<table
class='ax-center'
align='center'
style='margin: 0 auto;'
>
<tbody>
<tr>
<td
style='line-height: 24px; font-size: 16px; margin: 0;'
align='left'
>
<img
width='160'
height='160'
src='{{baseUrl}}/assets/mailing/email-reset.png'
alt='Reset Password'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
/>
</td>
</tr>
</tbody>
</table>
<table
class='s-10 w-full'
style='width: 100%;'
width='100%'
>
<tbody>
<tr>
<td
style='line-height: 40px; font-size: 40px; width: 100%; height: 40px; margin: 0;'
align='left'
width='100%'
height='40'
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class='fw-700 text-center text-lg'
style='font-size: 18px; line-height: 21.6px; font-weight: 700 !important;'
align='center'
>Reset Password</div>
<table
class='s-3 w-full'
style='width: 100%;'
width='100%'
>
<tbody>
<tr>
<td
style='line-height: 12px; font-size: 12px; width: 100%; height: 12px; margin: 0;'
align='left'
width='100%'
height='12'
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class='fw-400 text-center text-sm'
style='font-size: 14px; line-height: 16.8px; font-weight: 400 !important;'
align='center'
>Please change your password through the button below.
<br />
This link will expire after 24 hours or if it is used
once.</div>
<table
class='s-6 w-full'
style='width: 100%;'
width='100%'
>
<tbody>
<tr>
<td
style='line-height: 24px; font-size: 24px; width: 100%; height: 24px; margin: 0;'
align='left'
width='100%'
height='24'
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class='ax-center btn btn-black w-56'
align='center'
style='border-radius: 6px; border-collapse: separate !important; width: 224px; margin: 0 auto;'
width='224'
>
<tbody>
<tr>
<td
style='line-height: 24px; font-size: 16px; border-radius: 6px; width: 224px; margin: 0;'
align='center'
bgcolor='#000000'
width='224'
>
<a
href='{{link}}'
style='color: #ffffff; font-size: 16px; font-family: Helvetica, Arial, sans-serif; text-decoration: none; border-radius: 6px; line-height: 20px; display: block; font-weight: normal; white-space: nowrap; background-color: #000000; padding: 8px 12px; border: 1px solid #000000;'
>Change Password</a>
</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
<tr>
<td
style='line-height: 24px; font-size: 16px; padding-bottom: 32px; width: 100%; margin: 0;'
align='left'
width='100%'
>
<div
class='fw-400 text-secondary text-center text-xs'
style='color: #A3A3A3; font-size: 14px; font-weight: 400; line-height: 20px;'
align='center'
>This is an automated message. Please do not reply to this
email.</div>
</td>
</tr>
<tr>
<td
style='line-height: 24px; font-size: 16px; padding-bottom: 0; width: 100%; margin: 0;'
align='left'
width='100%'
>
<table class='s-6' style='width: 100%;' width='100%'>
<tbody>
<tr>
<td
style='line-height: 24px; font-size: 16px; padding-right: 0; margin: 0;'
align='left'
valign='top'
>
<table>
<tbody>
<tr>
<td>
<img
width='16'
height='16'
src='{{baseUrl}}/assets/mailing/logo.svg'
alt='Logo'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
/>
</td>
<td>
<img
width='116'
height='18'
src='{{baseUrl}}/assets/mailing/title-ufb.png'
alt='UserFeedback'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; display:inline'
/>
</td>
</tr>
</tbody>
</table>
</td>
<td align='left' valign='center'>
<table align='center' style='margin: 0 auto;'>
<tbody>
<tr>
<td
style='padding-right: 12px; margin: 0;'
align='left'
valign='top'
>
<img
width='16'
height='16'
src='{{baseUrl}}/assets/mailing/globe-fill.png'
alt='Website'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0; cursor: not-allowed;'
/>
</td>
<td
style='padding-right: 12px; margin: 0;'
align='left'
valign='top'
>
<a
href='https://github.com/line/abc-user-feedback'
style='color: #0d6efd;'
>
<img
width='16'
height='16'
src='{{baseUrl}}/assets/mailing/github-mark.png'
alt='GitHub'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
/>
</a>
</td>
<td
style='padding-right: 0; margin: 0;'
align='left'
valign='top'
>
<a
href='mailto:dl_abc_userfeedback@linecorp.com'
style='color: #0d6efd;'
>
<img
width='16'
height='16'
src='{{baseUrl}}/assets/mailing/mail-fill.png'
style='height: auto; line-height: 100%; outline: none; text-decoration: none; display: block; border-style: none; border-width: 0;'
/>
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

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