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

This commit is contained in:
root
2026-08-31 16:45:24 +09:00
commit 33453ecc55
3475 changed files with 850363 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
**/node_modules
**/.next
**/dist
**/.env*
!apps/web/.env.build
docker
volumes
**/.venv
**/.venv-*
**/*.egg-info
**/.cache
.turbo
**/.turbo
uploads
**/coverage
**/.swc
apps/docs/static
+291
View File
@@ -0,0 +1,291 @@
name: Deploy staging
run-name: Deploy staging from main
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Validate deployment settings
env:
STAGING_HOST: ${{ vars.STAGING_HOST }}
STAGING_PORT: ${{ vars.STAGING_PORT }}
STAGING_APP_DIR: ${{ vars.STAGING_APP_DIR }}
STAGING_COMPOSE_FILE: ${{ vars.STAGING_COMPOSE_FILE }}
STAGING_USER: ${{ secrets.STAGING_USER }}
STAGING_SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
STAGING_SSH_KNOWN_HOSTS: ${{ secrets.STAGING_SSH_KNOWN_HOSTS }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
ADMIN_CANDIDATE_TENANT_ID: ${{ vars.ADMIN_CANDIDATE_TENANT_ID }}
INITIAL_SUPER_ADMIN_PHONE_NUMBER: ${{ vars.INITIAL_SUPER_ADMIN_PHONE_NUMBER }}
ADMIN_WEB_URL: ${{ vars.ADMIN_WEB_URL }}
BASE_URL: ${{ vars.BASE_URL }}
SMTP_ENABLED: ${{ vars.SMTP_ENABLED }}
SMTP_HOST: ${{ vars.SMTP_HOST }}
SMTP_SENDER: ${{ vars.SMTP_SENDER }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
MASTER_API_KEY: ${{ secrets.MASTER_API_KEY }}
SSO_ISSUER: ${{ vars.SSO_ISSUER }}
SSO_CLIENT_ID: ${{ vars.SSO_CLIENT_ID }}
SSO_CLIENT_SECRET: ${{ secrets.SSO_CLIENT_SECRET }}
SECRETARY_ABC_API_KEY: ${{ secrets.SECRETARY_ABC_API_KEY }}
GITEA_API_URL: ${{ vars.GITEA_API_URL }}
GITEA_API_TOKEN: ${{ secrets.EXTERNAL_ISSUE_API_TOKEN }}
GITHUB_API_TOKEN: ${{ secrets.EXTERNAL_GITHUB_API_TOKEN }}
JIRA_API_EMAIL: ${{ vars.JIRA_API_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }}
JIRA_ISSUE_TYPE: ${{ vars.JIRA_ISSUE_TYPE }}
NAVER_WORKS_ENABLED: ${{ vars.NAVER_WORKS_ENABLED }}
NAVER_WORKS_API_BASE_URL: ${{ vars.NAVER_WORKS_API_BASE_URL }}
NAVER_WORKS_AUTH_URL: ${{ vars.NAVER_WORKS_AUTH_URL }}
NAVER_WORKS_BOT_ID: ${{ vars.NAVER_WORKS_BOT_ID }}
NAVER_WORKS_DEFAULT_ROOM_ID: ${{ vars.NAVER_WORKS_DEFAULT_ROOM_ID }}
NAVER_WORKS_SCOPE: ${{ vars.NAVER_WORKS_SCOPE }}
NAVER_WORKS_CLIENT_ID: ${{ secrets.NAVER_WORKS_CLIENT_ID }}
NAVER_WORKS_CLIENT_SECRET: ${{ secrets.NAVER_WORKS_CLIENT_SECRET }}
NAVER_WORKS_SERVICE_ACCOUNT: ${{ secrets.NAVER_WORKS_SERVICE_ACCOUNT }}
NAVER_WORKS_PRIVATE_KEY: ${{ secrets.NAVER_WORKS_PRIVATE_KEY }}
run: |
set -eu
STAGING_HOST="${STAGING_HOST:-172.16.10.175}"
STAGING_PORT="${STAGING_PORT:-22}"
STAGING_APP_DIR="${STAGING_APP_DIR:-/home/user/baron_qa}"
STAGING_COMPOSE_FILE="${STAGING_COMPOSE_FILE:-docker/docker-compose.prod.yml}"
export STAGING_HOST STAGING_PORT STAGING_APP_DIR STAGING_COMPOSE_FILE
required_vars="NEXT_PUBLIC_API_BASE_URL ADMIN_WEB_URL BASE_URL SSO_ISSUER SSO_CLIENT_ID ADMIN_CANDIDATE_TENANT_ID INITIAL_SUPER_ADMIN_PHONE_NUMBER NAVER_WORKS_ENABLED NAVER_WORKS_BOT_ID NAVER_WORKS_DEFAULT_ROOM_ID"
required_secrets="STAGING_USER STAGING_SSH_PRIVATE_KEY STAGING_SSH_KNOWN_HOSTS JWT_SECRET MASTER_API_KEY SSO_CLIENT_SECRET SECRETARY_ABC_API_KEY GITEA_API_TOKEN NAVER_WORKS_CLIENT_ID NAVER_WORKS_CLIENT_SECRET NAVER_WORKS_SERVICE_ACCOUNT NAVER_WORKS_PRIVATE_KEY"
for name in $required_vars $required_secrets; do
if [ -z "${!name:-}" ]; then
echo "Missing Gitea variable or secret: $name" >&2
exit 1
fi
done
test "$STAGING_COMPOSE_FILE" = "docker/docker-compose.prod.yml" || {
echo "STAGING_COMPOSE_FILE must point to docker/docker-compose.prod.yml" >&2
exit 1
}
echo "Deployment settings are present."
- name: Deploy to staging over SSH
env:
STAGING_HOST: ${{ vars.STAGING_HOST }}
STAGING_PORT: ${{ vars.STAGING_PORT }}
STAGING_APP_DIR: ${{ vars.STAGING_APP_DIR }}
STAGING_COMPOSE_FILE: ${{ vars.STAGING_COMPOSE_FILE }}
STAGING_USER: ${{ secrets.STAGING_USER }}
STAGING_SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }}
STAGING_SSH_KNOWN_HOSTS: ${{ secrets.STAGING_SSH_KNOWN_HOSTS }}
APP_ENV: ${{ vars.APP_ENV }}
WEB_PORT: ${{ vars.WEB_PORT }}
API_PORT: ${{ vars.API_PORT }}
SECRETARY_API_PORT: ${{ vars.SECRETARY_API_PORT }}
MYSQL_PORT: ${{ vars.MYSQL_PORT }}
MYSQL_SECRETARY_PORT: ${{ vars.MYSQL_SECRETARY_PORT }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
ADMIN_CANDIDATE_TENANT_ID: ${{ vars.ADMIN_CANDIDATE_TENANT_ID }}
ADMIN_WEB_URL: ${{ vars.ADMIN_WEB_URL }}
BASE_URL: ${{ vars.BASE_URL }}
SMTP_ENABLED: ${{ vars.SMTP_ENABLED }}
SMTP_HOST: ${{ vars.SMTP_HOST }}
SMTP_PORT: ${{ vars.SMTP_PORT }}
SMTP_SENDER: ${{ vars.SMTP_SENDER }}
SMTP_TLS: ${{ vars.SMTP_TLS }}
SMTP_CIPHER_SPEC: ${{ vars.SMTP_CIPHER_SPEC }}
SMTP_OPPORTUNISTIC_TLS: ${{ vars.SMTP_OPPORTUNISTIC_TLS }}
ACCESS_TOKEN_EXPIRED_TIME: ${{ vars.ACCESS_TOKEN_EXPIRED_TIME }}
REFRESH_TOKEN_EXPIRED_TIME: ${{ vars.REFRESH_TOKEN_EXPIRED_TIME }}
AUTO_MIGRATION: ${{ vars.AUTO_MIGRATION }}
OPENSEARCH_USE: ${{ vars.OPENSEARCH_USE }}
OPENSEARCH_NODE: ${{ vars.OPENSEARCH_NODE }}
OPENSEARCH_USERNAME: ${{ vars.OPENSEARCH_USERNAME }}
OPENSEARCH_PASSWORD: ${{ vars.OPENSEARCH_PASSWORD }}
SSO_ISSUER: ${{ vars.SSO_ISSUER }}
SSO_CLIENT_ID: ${{ vars.SSO_CLIENT_ID }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
MASTER_API_KEY: ${{ secrets.MASTER_API_KEY }}
INITIAL_SUPER_ADMIN_PHONE_NUMBER: ${{ vars.INITIAL_SUPER_ADMIN_PHONE_NUMBER }}
ADMIN_CANDIDATE_EMAILS: ${{ secrets.ADMIN_CANDIDATE_EMAILS }}
SMTP_USERNAME: ${{ secrets.SMTP_USERNAME }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
SSO_CLIENT_SECRET: ${{ secrets.SSO_CLIENT_SECRET }}
SECRETARY_ABC_API_KEY: ${{ secrets.SECRETARY_ABC_API_KEY }}
GITEA_API_URL: ${{ vars.GITEA_API_URL }}
GITEA_API_TOKEN: ${{ secrets.EXTERNAL_ISSUE_API_TOKEN }}
GITHUB_API_TOKEN: ${{ secrets.EXTERNAL_GITHUB_API_TOKEN }}
JIRA_API_EMAIL: ${{ vars.JIRA_API_EMAIL }}
JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
JIRA_WEBHOOK_SECRET: ${{ secrets.JIRA_WEBHOOK_SECRET }}
JIRA_ISSUE_TYPE: ${{ vars.JIRA_ISSUE_TYPE }}
NAVER_WORKS_ENABLED: ${{ vars.NAVER_WORKS_ENABLED }}
NAVER_WORKS_API_BASE_URL: ${{ vars.NAVER_WORKS_API_BASE_URL }}
NAVER_WORKS_AUTH_URL: ${{ vars.NAVER_WORKS_AUTH_URL }}
NAVER_WORKS_BOT_ID: ${{ vars.NAVER_WORKS_BOT_ID }}
NAVER_WORKS_DEFAULT_ROOM_ID: ${{ vars.NAVER_WORKS_DEFAULT_ROOM_ID }}
NAVER_WORKS_SCOPE: ${{ vars.NAVER_WORKS_SCOPE }}
NAVER_WORKS_MAX_RETRIES: ${{ vars.NAVER_WORKS_MAX_RETRIES }}
NAVER_WORKS_MAX_MESSAGE_LENGTH: ${{ vars.NAVER_WORKS_MAX_MESSAGE_LENGTH }}
NAVER_WORKS_ACCESS_TOKEN: ${{ secrets.NAVER_WORKS_ACCESS_TOKEN }}
NAVER_WORKS_CLIENT_ID: ${{ secrets.NAVER_WORKS_CLIENT_ID }}
NAVER_WORKS_CLIENT_SECRET: ${{ secrets.NAVER_WORKS_CLIENT_SECRET }}
NAVER_WORKS_SERVICE_ACCOUNT: ${{ secrets.NAVER_WORKS_SERVICE_ACCOUNT }}
NAVER_WORKS_PRIVATE_KEY: ${{ secrets.NAVER_WORKS_PRIVATE_KEY }}
run: |
set -eu
set -o pipefail
umask 077
STAGING_HOST="${STAGING_HOST:-172.16.10.175}"
STAGING_PORT="${STAGING_PORT:-22}"
STAGING_APP_DIR="${STAGING_APP_DIR:-/home/user/baron_qa}"
STAGING_COMPOSE_FILE="${STAGING_COMPOSE_FILE:-docker/docker-compose.prod.yml}"
export STAGING_HOST STAGING_PORT STAGING_APP_DIR STAGING_COMPOSE_FILE
ssh_dir="$RUNNER_TEMP/staging-ssh"
mkdir -p "$ssh_dir"
chmod 700 "$ssh_dir"
key_file="$ssh_dir/id_ed25519"
known_hosts_file="$ssh_dir/known_hosts"
printf '%s\n' "$STAGING_SSH_PRIVATE_KEY" > "$key_file"
printf '%s\n' "$STAGING_SSH_KNOWN_HOSTS" > "$known_hosts_file"
chmod 600 "$key_file" "$known_hosts_file"
trap 'rm -rf "$ssh_dir"' EXIT
derived_public_key="$ssh_dir/id_ed25519.pub"
if ! ssh-keygen -y -f "$key_file" > "$derived_public_key" 2>/dev/null; then
echo "STAGING_SSH_PRIVATE_KEY is invalid or passphrase-protected." >&2
exit 1
fi
key_fingerprint="$(ssh-keygen -lf "$derived_public_key" | awk '{print $2}')"
echo "SSH target: $STAGING_USER@$STAGING_HOST:$STAGING_PORT"
echo "SSH key fingerprint: $key_fingerprint"
shell_quote() {
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\"'\\\"'/g")"
}
remote="$STAGING_USER@$STAGING_HOST"
remote_app_dir="$(shell_quote "$STAGING_APP_DIR")"
ssh_args=(
-i "$key_file"
-p "$STAGING_PORT"
-o BatchMode=yes
-o IdentitiesOnly=yes
-o StrictHostKeyChecking=yes
-o UserKnownHostsFile="$known_hosts_file"
)
ssh "${ssh_args[@]}" "$remote" "mkdir -p $remote_app_dir"
ssh "${ssh_args[@]}" "$remote" "rm -f $remote_app_dir/apps/secretary-api/alembic/versions/0002_role_access_workspaces.py $remote_app_dir/apps/secretary-api/alembic/versions/0009_preassigned_admin_candidates.py"
tar \
--exclude='.git' \
--exclude='node_modules' \
--exclude='.next' \
--exclude='dist' \
--exclude='uploads' \
--exclude='.venv' \
--exclude='.venv-*' \
--exclude='.cache' \
--exclude='.turbo' \
--exclude='apps/docs/static' \
-czf - . | ssh "${ssh_args[@]}" "$remote" "tar -xzf - -C $remote_app_dir"
payload_file="$RUNNER_TEMP/staging-env.json"
python3 - <<'PY' > "$payload_file"
import base64
import json
import os
keys = """
APP_ENV WEB_PORT API_PORT SECRETARY_API_PORT MYSQL_PORT MYSQL_SECRETARY_PORT
NEXT_PUBLIC_API_BASE_URL ADMIN_WEB_URL BASE_URL SMTP_ENABLED SMTP_HOST SMTP_PORT SMTP_SENDER
SMTP_TLS SMTP_CIPHER_SPEC SMTP_OPPORTUNISTIC_TLS ACCESS_TOKEN_EXPIRED_TIME
REFRESH_TOKEN_EXPIRED_TIME AUTO_MIGRATION OPENSEARCH_USE OPENSEARCH_NODE
OPENSEARCH_USERNAME OPENSEARCH_PASSWORD SSO_ISSUER SSO_CLIENT_ID JWT_SECRET
MASTER_API_KEY INITIAL_SUPER_ADMIN_PHONE_NUMBER ADMIN_CANDIDATE_EMAILS ADMIN_CANDIDATE_TENANT_ID
SMTP_USERNAME SMTP_PASSWORD SSO_CLIENT_SECRET SECRETARY_ABC_API_KEY GITEA_API_URL GITEA_API_TOKEN GITHUB_API_TOKEN
JIRA_API_EMAIL JIRA_API_TOKEN JIRA_WEBHOOK_SECRET JIRA_ISSUE_TYPE
NAVER_WORKS_ENABLED NAVER_WORKS_API_BASE_URL NAVER_WORKS_AUTH_URL NAVER_WORKS_ACCESS_TOKEN
NAVER_WORKS_BOT_ID NAVER_WORKS_DEFAULT_ROOM_ID NAVER_WORKS_CLIENT_ID NAVER_WORKS_CLIENT_SECRET
NAVER_WORKS_SERVICE_ACCOUNT NAVER_WORKS_PRIVATE_KEY NAVER_WORKS_SCOPE NAVER_WORKS_MAX_RETRIES
NAVER_WORKS_MAX_MESSAGE_LENGTH
""".split()
values = {key: os.environ.get(key, "") for key in keys}
encoded = base64.b64encode(
json.dumps(values, ensure_ascii=False, separators=(",", ":")).encode()
).decode()
print(encoded)
PY
remote_command="set -eu
APP_DIR=$(shell_quote "$STAGING_APP_DIR")
COMPOSE_FILE=$(shell_quote "$STAGING_COMPOSE_FILE")
test -d \"\$APP_DIR\"
cd \"\$APP_DIR\"
python3 -c 'import base64,json,os,subprocess,sys,time,urllib.request
compose=sys.argv[1]
payload=sys.stdin.buffer.read().strip()
if not payload:
raise SystemExit(\"Deployment payload is empty.\")
values=json.loads(base64.b64decode(payload))
os.environ.update(values)
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"config\",\"--quiet\"],check=True)
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"up\",\"-d\",\"--build\"],check=True)
checks=[\"http://127.0.0.1:\"+os.environ.get(\"WEB_PORT\",\"3030\")+\"/api/health\"]
for url in checks:
last_error=\"no response\"
for _ in range(60):
try:
with urllib.request.urlopen(url,timeout=3) as response:
if response.status < 500:
break
last_error=\"HTTP \"+str(response.status)
except Exception as error:
last_error=repr(error)
time.sleep(2)
else:
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"ps\"],check=False)
for service in (\"api\",\"secretary-api\",\"web\",\"mysql\",\"mysql-secretary\"):
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"logs\",\"--tail=100\",service],check=False)
raise SystemExit(\"Health check failed: \"+url+\" (\"+last_error+\")\")
secretary_check=[\"docker\",\"compose\",\"-f\",compose,\"exec\",\"-T\",\"secretary-api\",\"python3\",\"-c\",\"import urllib.request; urllib.request.urlopen(\\\"http://127.0.0.1:8010/api/health\\\", timeout=5)\"]
secretary_error=\"no response\"
for _ in range(60):
try:
subprocess.run(secretary_check,check=True)
break
except subprocess.CalledProcessError as error:
secretary_error=repr(error)
time.sleep(2)
else:
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"ps\"],check=False)
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"logs\",\"--tail=200\",\"secretary-api\"],check=False)
raise SystemExit(\"Secretary API health check failed: \"+secretary_error)
subprocess.run([\"docker\",\"compose\",\"-f\",compose,\"ps\"],check=True)
print(\"Staging deployment and health checks passed.\")' \"\$COMPOSE_FILE\""
cat "$payload_file" | ssh \
-i "$key_file" \
-p "$STAGING_PORT" \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o UserKnownHostsFile="$known_hosts_file" \
"$remote" "$remote_command"
rm -f "$payload_file"
+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
+67
View File
@@ -0,0 +1,67 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules
.pnp
.pnp.js
# Python local environments and generated files
**/.venv/
**/__pycache__/
*.py[cod]
*.egg-info/
# 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
# Runtime-uploaded files
/uploads/
.cache
# playwright
test-results/
playwright-report/
blob-report/
playwright/
# JetBrains
.idea
# Local BARON-SSO/EGBIM identity exports (contains personal data)
users_export_*.csv
scripts/generated/egbim_migration/identity_mapping_template.csv
+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
```
+210
View File
@@ -0,0 +1,210 @@
# Gitea 스테이징 등록 변수
BARON User Feedback와 secretary-api를 `172.16.10.175:3030`으로 배포할 때 Gitea에 등록할 환경변수와 Secret 정리입니다.
## 1. 스테이징 기본값
### Variables
| 이름 | 등록값 | 용도 |
| ---------------------------- | ----------------------------------------------- | ----------------------------------------------- |
| `APP_ENV` | `staging` | 실행 환경 |
| `WEB_PORT` | `3030` | 외부 웹 포트 |
| `API_PORT` | `4000` | Docker 내부 API 포트 (host에 공개하지 않음) |
| `SECRETARY_API_PORT` | `8010` | Docker 내부 FastAPI 포트 (host에 공개하지 않음) |
| `MYSQL_PORT` | 등록 불필요 | prod Compose는 DB host port를 공개하지 않음 |
| `MYSQL_SECRETARY_PORT` | 등록 불필요 | prod Compose는 DB host port를 공개하지 않음 |
| `NEXT_PUBLIC_API_BASE_URL` | `http://172.16.10.175:3030` | Web reverse proxy를 통한 브라우저 API 주소 |
| `ADMIN_WEB_URL` | `http://172.16.10.175:3030` | 관리자 웹 주소 및 OAuth 기준 주소 |
| `BASE_URL` | `http://172.16.10.175:3030` | Web reverse proxy를 통한 API 기준 주소 |
| `SMTP_ENABLED` | `false` (현재 SSO 전용) 또는 `true` (SMTP 사용) | 이메일 기능 활성화 여부 |
| `SMTP_HOST` | 사내 SMTP 호스트 | 메일 서버 |
| `SMTP_PORT` | `25` 또는 사내 SMTP 포트 | 메일 서버 포트 |
| `SMTP_SENDER` | 사내 발신 이메일 | 메일 발신자 |
| `SMTP_TLS` | `false` 또는 `true` | SMTP TLS 사용 여부 |
| `SMTP_CIPHER_SPEC` | 사내 SMTP 정책값 | TLS cipher 설정 |
| `SMTP_OPPORTUNISTIC_TLS` | `false` 또는 `true` | SMTP opportunistic TLS |
| `ACCESS_TOKEN_EXPIRED_TIME` | `10m` | Access Token 만료시간 |
| `REFRESH_TOKEN_EXPIRED_TIME` | `1h` | Refresh Token 만료시간 |
| `AUTO_MIGRATION` | `true` | API 시작 시 마이그레이션 |
| `OPENSEARCH_USE` | `false` | OpenSearch 사용 여부 |
| `OPENSEARCH_NODE` | 빈 값 | OpenSearch 미사용 |
| `OPENSEARCH_USERNAME` | 빈 값 | OpenSearch 미사용 |
| `OPENSEARCH_PASSWORD` | 빈 값 | OpenSearch 미사용 |
> 스테이징 DB는 외부 host port를 사용하지 않습니다. 컨테이너 내부에서는 `mysql:3306`, `mysql-secretary:3306`으로 접근합니다. 외부 DB 접속이 필요하면 SSH 터널을 사용합니다.
> | `SSO_ISSUER` | `https://sso.hmac.kr/oidc` | secretary-api용 SSO issuer |
> | `SSO_CLIENT_ID` | `838cd69d-e722-41da-9f79-b3c42a509ef2` | BARON-SSO Client ID |
`INTERNAL_API_BASE_URL`, `SUPPORT_API_BASE_URL`, `ABC_API_BASE_URL`는 Docker 내부 주소이므로 Gitea에 별도 등록하지 않습니다.
```text
INTERNAL_API_BASE_URL=http://api:4000
SUPPORT_API_BASE_URL=http://secretary-api:8010
ABC_API_BASE_URL=http://api:4000
```
다음 값도 Compose에 고정되어 있으므로 Gitea에 별도 등록하지 않습니다.
| 이름 | 고정값 |
| ------------------- | -------------------------------------------------------------------------------- |
| `APP_NAME` | `secretary-api` |
| `APP_HOST` | `0.0.0.0` |
| `APP_PORT` | `8010` |
| `UPLOAD_ROOT_DIR` | `/app/uploads` |
| `MYSQL_PRIMARY_URL` | `mysql://userfeedback:userfeedback@mysql:3306/userfeedback` |
| `DATABASE_URL` | `mysql+pymysql://baron_support:baron_support@mysql-secretary:3306/baron_support` |
다음은 선택 기능을 사용할 때만 추가합니다.
| 이름 | 등록값 |
| ------------------------------------ | --------------------------- |
| `MYSQL_SECONDARY_URLS` | 보조 MySQL URL JSON 배열 |
| `AUTO_FEEDBACK_DELETION_ENABLED` | `false` |
| `AUTO_FEEDBACK_DELETION_PERIOD_DAYS` | 자동 삭제 사용 시 보존 일수 |
## 2. Gitea Secrets
### API 및 관리자 인증
| 이름 | 등록값 |
| ---------------------------------- | -------------------------------------------------------- |
| `JWT_SECRET` | 긴 무작위 문자열 |
| `MASTER_API_KEY` | ABC 전체 API 관리용 무작위 키 |
| `INITIAL_SUPER_ADMIN_PHONE_NUMBER` | 선택값: 초기 SUPER 관리자 자동 지정용 BARON-SSO 전화번호 |
| `ADMIN_CANDIDATE_EMAILS` | 관리자 후보 이메일 목록을 쉼표로 연결 |
예시:
```text
ADMIN_CANDIDATE_EMAILS=admin1@example.com,admin2@example.com
```
현재 로컬에 등록된 후보 이메일은 다음과 같습니다. 스테이징에서도 동일하게 사용할 때만 등록합니다.
```text
cyhan@samaneng.com,hsmoon@hanmaceng.co.kr,hikim2@samaneng.com,thlee3@samaneng.com
```
### SMTP
| 이름 | 등록값 |
| --------------- | ------------- |
| `SMTP_USERNAME` | SMTP 계정 |
| `SMTP_PASSWORD` | SMTP 비밀번호 |
SMTP를 인증 없이 사용하면 두 값은 빈 값으로 둡니다.
### BARON-SSO
| 이름 | 등록값 |
| ------------------- | ----------------------- |
| `SSO_CLIENT_SECRET` | BARON-SSO Client Secret |
BARON-SSO에 다음 Redirect URI도 등록해야 합니다.
```text
http://172.16.10.175:3030/api/auth/baron-sso/callback
```
## 3. ABC API Key와 workspace 매핑
Gitea에는 ABC API Key와 내부 매핑 조회용 `MASTER_API_KEY`를 Secret으로 등록합니다. 프로젝트·채널 ID는 Gitea 변수나 수동 SQL로 관리하지 않고, 로그인 시 API가 ABC DB의 프로젝트·채널을 workspace code 기준으로 조회하여 `baron_support.workspace_channel_mappings`에 자동 등록·갱신합니다.
```text
SECRETARY_ABC_API_KEY=<스테이징 ABC API Key>
MASTER_API_KEY=<API와 secretary-api가 공유하는 내부 인증 키>
```
자동 매핑 대상은 다음 조건을 만족해야 합니다.
- workspace code와 동일한 이름의 ABC 프로젝트가 정확히 1개일 것
- 해당 프로젝트의 채널이 정확히 1개이거나, workspace code/프로젝트 이름과 일치하는 채널이 정확히 1개일 것
- 조건을 만족하면 기존 매핑은 보완하고, 매핑 행이 없으면 새로 INSERT할 것
현재 매핑 확인:
```sql
SELECT
w.id,
w.workspace_code,
w.workspace_name,
m.id AS mapping_id,
m.abc_project_id,
m.abc_channel_id,
m.is_active
FROM workspaces w
LEFT JOIN workspace_channel_mappings m
ON m.workspace_id = w.id
WHERE w.is_active = 1
ORDER BY w.id, m.id;
```
배포 후 사용자가 로그아웃/로그인하거나 `/api/access/me`를 호출하면 EGBIM, TOVA 등의 누락 매핑이 자동으로 채워집니다. 프로젝트·채널 이름이 여러 개로 모호하면 잘못된 연결을 막기 위해 자동 등록하지 않으므로, 이 경우 ABC DB의 이름을 확인해야 합니다.
## 4. DB 접속값
Docker 내부 서비스 간 접속값은 다음과 같습니다.
```text
# ABC 내장 DB
MYSQL_PRIMARY_URL=mysql://userfeedback:userfeedback@mysql:3306/userfeedback
# secretary-api 구축 DB
DATABASE_URL=mysql+pymysql://baron_support:baron_support@mysql-secretary:3306/baron_support
```
현재 `docker-compose.prod.yml`에는 DB 계정과 비밀번호가 직접 작성되어 있습니다.
```text
ABC DB: userfeedback / userfeedback
구축 DB: baron_support / baron_support
```
운영 배포 전에는 다음 값을 Gitea Secret으로 분리하고 Compose에서 참조하도록 변경하는 것을 권장합니다.
```text
MYSQL_ROOT_PASSWORD
MYSQL_PASSWORD
SECRETARY_MYSQL_ROOT_PASSWORD
SECRETARY_MYSQL_PASSWORD
```
## 5. CI/CD 배포용 변수
Gitea Actions에서 SSH 배포 방식을 사용할 경우 다음 값을 등록합니다.
### Variables
| 이름 | 등록값 |
| ---------------------- | ----------------------------------------------------------------- |
| `STAGING_HOST` | `172.16.10.175` (미등록 시 workflow 기본값 사용) |
| `STAGING_PORT` | `22` (미등록 시 workflow가 기본값으로 사용) |
| `STAGING_APP_DIR` | `/home/user/baron_qa` (미등록 시 workflow 기본값 사용) |
| `STAGING_COMPOSE_FILE` | `docker/docker-compose.prod.yml` (미등록 시 workflow 기본값 사용) |
| `STAGING_WEB_PORT` | `3030` |
### Secrets
| 이름 | 등록값 |
| ------------------------- | -------------------------------- |
| `STAGING_USER` | 스테이징 서버 SSH 사용자 |
| `STAGING_SSH_PRIVATE_KEY` | 배포용 SSH private key |
| `STAGING_SSH_KNOWN_HOSTS` | 스테이징 서버의 `known_hosts` 값 |
Docker Registry를 사용하는 경우 추가로 등록합니다.
```text
REGISTRY_URL
REGISTRY_USERNAME
REGISTRY_PASSWORD
```
## 6. 등록 전 확인사항
- 현재 스테이징은 `SMTP_ENABLED=false`로 등록하면 SMTP 없이 BARON-SSO 로그인과 권한 분기만 검증할 수 있습니다. 추후 SMTP 정보를 확보하면 `true`로 변경합니다.
- Gitea Secret에는 API Key, `MASTER_API_KEY`, JWT Secret, SMTP 비밀번호, SSO Client Secret을 등록합니다.
- ABC 프로젝트·채널 매핑은 로그인 시 ABC DB와 자동 동기화되며, 이름이 모호한 경우에만 ABC DB의 프로젝트·채널 이름을 확인합니다.
- `INITIAL_SUPER_ADMIN_PHONE_NUMBER`는 선택값입니다. 등록하면 해당 BARON-SSO 전화번호 사용자를 초기 SUPER 관리자로 자동 승격하며, 미등록 시에도 API는 정상 기동합니다.
- 외부 공개 포트는 Web `3030` 하나이며, Web이 Docker 내부 `api:4000``secretary-api:8010`으로 reverse proxy합니다.
- 로컬 Compose에 존재하는 API Key와 DB 비밀번호를 스테이징에 재사용하지 말고 스테이징용 Secret을 별도로 발급합니다.
+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.
+281
View File
@@ -0,0 +1,281 @@
# ABC User Feedback
ABC User Feedback는 BARON-SSO로 인증한 사용자가 피드백을 등록하고, 관리자가 피드백·이슈·댓글·첨부파일을 처리하는 통합 지원 플랫폼입니다. 이 저장소는 ABC User Feedback를 기반으로 한 관리자 콘솔과 Secretary 업무 API를 함께 관리합니다.
이 문서는 저장소의 시작점입니다. 상세 설계와 작업 이력은 [`docs/`](./docs/) 아래에 보존하고, 여기에는 실제 실행·검증·배포 순서와 문서 선택 기준을 정리합니다.
## 1. 빠른 시작
### 로컬 실행
필요 조건:
- Node.js와 `pnpm@10.32.1`
- Docker 및 Docker Compose
- `apps/secretary-api/.venv`와 Secretary API 의존성
권장 실행 명령은 루트의 `start-local.sh`입니다. 이 스크립트가 로컬 MySQL 2개와 smtp4dev를 올린 뒤 Web, ABC API, Secretary API를 함께 실행합니다.
```bash
pnpm install
./start-local.sh
```
접속 주소:
- Web: <http://127.0.0.1:3100>
- ABC API: <http://127.0.0.1:4000>
- ABC Swagger: <http://127.0.0.1:4000/docs>
- ABC 관리자 Swagger: <http://127.0.0.1:4000/admin-docs>
- Secretary API: <http://127.0.0.1:8010>
- Secretary Swagger: <http://127.0.0.1:8010/docs>
- SMTP 테스트함: <http://127.0.0.1:5080>
이미 인프라를 실행한 상태에서 개발 서버만 시작하려면 다음을 사용할 수 있습니다.
```bash
pnpm dev:local
```
포트 `3100`, `4000`, `8010`을 사용하는 프로세스가 있으면 스크립트가 임의로 종료하지 않고 중단합니다. 먼저 사용 중인 프로세스를 확인한 뒤 종료하고 다시 실행합니다.
```bash
lsof -nP -iTCP:3100 -sTCP:LISTEN
lsof -nP -iTCP:4000 -sTCP:LISTEN
lsof -nP -iTCP:8010 -sTCP:LISTEN
```
### 로컬 검증
```bash
pnpm lint
pnpm typecheck
pnpm build
```
E2E는 테스트용 데이터베이스를 초기화할 수 있으므로 로컬 개발 데이터와 분리된 환경에서 실행합니다.
```bash
pnpm test:e2e
```
## 2. 서비스 구조
```text
브라우저
apps/web Next.js 화면 및 내부 BFF
├─▶ apps/api NestJS, ABC 피드백·이슈 API
│ └─▶ ABC MySQL
└─▶ apps/secretary-api FastAPI, SSO 접근·워크스페이스·업무 보조 API
└─▶ Secretary MySQL
외부 연동: BARON-SSO · Gitea · SMTP · R2/S3 호환 스토리지 · OpenSearch(선택)
```
| 구성요소 | 책임 | 기본 포트 |
| --- | --- | ---: |
| `apps/web` | 관리자 콘솔, 사용자 피드백 화면, Secretary 내부 BFF | `3100` 로컬 / `3030` Docker |
| `apps/api` | ABC 프로젝트·채널·피드백·이슈·댓글·필드·통계 API | `4000` |
| `apps/secretary-api` | SSO 접근권한, 워크스페이스, 지원 업무 API | `8010` |
| ABC MySQL | 피드백과 이슈 원본 데이터 | `13306` 로컬 |
| Secretary MySQL | 접근권한과 업무 보조 데이터 | `13308` 로컬 |
## 3. 데이터와 SSOT 원칙
피드백의 원본은 관리자 화면이 아니라 ABC API/ABC DB입니다. 관리자 콘솔과 사용자 페이지 모두 같은 ABC API를 조회·작성·수정·삭제에 사용해야 합니다.
ABC가 보유하는 원본:
- 피드백 ID, 제목, 내용, 생성일, 수정일
- 작성자 이름·이메일·부서·연락처와 SSO 식별자
- 카테고리, 비밀글 여부, IP 주소, MAC 주소
- 중요도와 피드백 처리 상태
- 첨부파일, 댓글, 내부 메모
- ABC 이슈 연결 관계
Secretary가 보유하는 데이터:
- SSO 접근권한과 역할
- 프로젝트/워크스페이스 접근 설정
- 업무용 담당자·승인·알림·매핑 메타데이터
Secretary DB에 피드백 제목·내용·상태를 별도로 복제하지 않습니다. 연결이 필요하면 `abc_feedback_id` 같은 식별자만 보조 데이터로 사용합니다. 피드백 상태와 이슈 상태도 서로 독립적으로 관리합니다.
## 4. 인증과 권한 흐름
1. 사용자가 BARON-SSO OAuth/OIDC 로그인 화면으로 이동합니다.
2. Web의 callback이 인증 코드를 ABC API에 전달합니다.
3. ABC API가 SSO 프로필을 조회하고 사용자·테넌트 정보를 반영한 JWT를 발급합니다.
4. Web은 세션 쿠키로 JWT를 유지합니다.
5. 접근 가능한 프로젝트와 워크스페이스를 조회한 뒤 관리자 통합 대시보드 또는 사용자 피드백 화면으로 분기합니다.
관련 구현 위치:
- SSO callback: [`apps/web/src/features/auth/sign-in-with-oauth/lib/use-oauth-callback.ts`](./apps/web/src/features/auth/sign-in-with-oauth/lib/use-oauth-callback.ts)
- API 인증: [`apps/api/src/domains/admin/auth/`](./apps/api/src/domains/admin/auth/)
- Web 접근 제어: [`apps/web/src/proxy.ts`](./apps/web/src/proxy.ts)
- Secretary 접근 정보: [`apps/secretary-api/`](./apps/secretary-api/)
SSO 프로필의 `name`, `email`, `phones`, `employee_id`, `status` 등의 필드를 사용할 때는 BARON-SSO의 `profile` scope와 실제 callback 응답을 함께 확인합니다. 토큰, client secret, API key는 코드나 README에 기록하지 않습니다.
## 5. 주요 기능 사용 가이드
### 사용자 피드백
사용자는 프로젝트/채널의 필드 설정에 따라 피드백을 등록합니다. 현재 확장 필드에는 구분, 중요도, IP, MAC 주소 등이 포함될 수 있으며, 비밀글과 첨부파일을 지원합니다. 사용자 목록은 제목·내용·작성자 검색, 작성자 본인 글 필터, 구분 필터, 정렬, 10건 단위 페이지 이동을 제공합니다.
### 관리자 피드백 처리
관리자는 피드백 상태, 중요도, 피드백 담당자를 관리하고 댓글과 내부 메모를 구분해 기록합니다. 내부 메모는 관리자에게만 노출되며 일반 댓글과 동시에 저장되지 않아야 합니다. 피드백을 이슈에 연결해도 피드백 상태와 이슈 상태는 각각 별도로 처리합니다.
### 이슈와 Gitea
이슈를 연결한 뒤 이슈 담당자를 지정하고 Gitea 이슈를 생성·연결·동기화할 수 있습니다. 하나의 이슈에 여러 피드백을 연결할 수 있으며, 연결된 피드백 목록과 Gitea 상태를 확인합니다.
### 통합 관리자 대시보드
여러 프로젝트의 관리자에게 지정된 사용자는 홈 버튼을 통해 통합 대시보드로 이동합니다. 피드백 처리 탭에서는 담당자 지정·댓글·피드백 상태를, 이슈 처리 탭에서는 Gitea 연결·이슈 상태·연결 피드백을 프로젝트별로 처리합니다. 대시보드의 집계와 Todo는 권한이 있는 프로젝트만 대상으로 합니다.
## 6. API와 Swagger
ABC API 문서는 공개 연동 API와 관리자 API를 분리합니다.
| 구분 | 로컬 경로 | 주요 인증 |
| --- | --- | --- |
| 공개 API Swagger | `/docs` | API Key가 필요한 엔드포인트는 API Key |
| 관리자 API Swagger | `/admin-docs` | JWT 및 권한 |
| 공개 OpenAPI JSON | `/docs-json` | 환경 설정에 따름 |
| 관리자 OpenAPI JSON | `/admin-docs-json` | 환경 설정에 따름 |
| Secretary Swagger | `:8010/docs` | FastAPI 설정에 따름 |
로컬에서는 API 포트로 직접 확인합니다. Docker 스테이징에서는 API 컨테이너 포트를 외부에 별도로 열지 않고 Web reverse proxy를 통해 다음 경로를 사용합니다. 최신 Web 이미지가 배포된 뒤 동작합니다.
- <https://feedback.hmac.kr/docs>
- <https://feedback.hmac.kr/admin-docs>
- <https://feedback.hmac.kr/secretary-docs>
- Secretary OpenAPI JSON: <https://feedback.hmac.kr/secretary-docs/openapi.json>
API 패키징 시에는 화면용 Next.js `/api/support/*` BFF를 외부 계약으로 사용하지 않고, NestJS 공개/관리자 API와 Secretary API를 공식 경계로 취급합니다. API 버전, 페이지네이션, 동적 필드, 오류 형식, 댓글 공개 범위는 [`docs/api-packaging-tasks.md`](<./docs/api-packaging-tasks.md>)를 기준으로 확정합니다.
## 7. 환경변수와 비밀값
환경별 값을 코드와 문서에 하드코딩하지 않습니다. 스테이징에서는 Gitea Actions 변수/Secret 또는 서버의 실제 환경 주입 방식을 사용합니다.
주요 변수 그룹:
- 실행: `APP_ENV`, `WEB_PORT`, `APP_PORT`, `SECRETARY_API_PORT`
- Web/API 주소: `NEXT_PUBLIC_API_BASE_URL`, `ADMIN_WEB_URL`, `BASE_URL`
- 인증: `JWT_SECRET`, `MASTER_API_KEY`, `SSO_ISSUER`, `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET`
- 연동: `GITEA_API_URL`, `GITEA_API_TOKEN`, SMTP 변수, `SECRETARY_ABC_API_KEY`
- 운영: `AUTO_MIGRATION`, `OPENSEARCH_USE` 및 OpenSearch 변수
Docker 내부 서비스는 `api:4000`, `secretary-api:8010`, `mysql:3306`, `mysql-secretary:3306`으로 통신합니다. 브라우저에 노출되는 외부 포트는 Web 포트 하나로 제한합니다. 환경변수 전체 목록과 Gitea 등록 규칙은 [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md)를 확인합니다.
R2/S3 호환 첨부파일은 버킷과 endpoint를 환경 또는 관리자 설정으로 주입합니다. Access Key, Secret Key, API Token은 절대 커밋하지 않습니다. 이미지·첨부파일 저장 원칙은 [`GUIDE.md`](./GUIDE.md)를 참고합니다.
## 8. 스테이징 배포
배포 전 로컬:
```bash
git status
pnpm lint
pnpm typecheck
pnpm build
```
스테이징 서버에서는 실제 환경 파일 또는 배포 시스템이 제공하는 환경을 사용해 Compose 설정을 먼저 검증합니다.
```bash
cd ~/baron_qa
docker compose --env-file <실제-환경파일> \
-f docker/docker-compose.prod.yml config
docker compose --env-file <실제-환경파일> \
-f docker/docker-compose.prod.yml up -d --build
docker compose --env-file <실제-환경파일> \
-f docker/docker-compose.prod.yml ps
```
기존 데이터가 있는 서버에서 `docker compose down -v`를 실행하지 않습니다. `-v`는 DB 볼륨을 삭제할 수 있습니다. 최초 배포나 스키마 변경 시 백업, `AUTO_MIGRATION`, 로그, health check를 확인합니다.
상세 순서는 [`STAGING_DEPLOYMENT_CHECKLIST.md`](./STAGING_DEPLOYMENT_CHECKLIST.md)를 따릅니다.
## 9. 문제 해결 순서
1. 브라우저 주소가 `localhost`/`127.0.0.1`인지, 스테이징 도메인인지 확인합니다.
2. Network에서 요청 URL과 응답 코드, 특히 `401`, `404`, `500`을 확인합니다.
3. Web이 API를 `localhost`가 아닌 Docker 내부 `api:4000`으로 호출하는지 확인합니다.
4. SSO callback URL의 host, scheme, path가 BARON-SSO 등록값과 같은지 확인합니다.
5. 프로젝트/채널/워크스페이스 이름과 ABC 매핑을 확인합니다.
6. 서버 로그를 확인합니다.
```bash
docker compose --env-file <실제-환경파일> \
-f docker/docker-compose.prod.yml logs --tail=200 web api secretary-api
```
데이터가 보이지 않는다고 DB를 초기화하거나 볼륨을 삭제하지 않습니다. 먼저 API 응답의 프로젝트 ID, 채널 ID, 테넌트, 권한을 확인합니다.
## 10. 상세 문서 목차
### 운영·배포
- [`STAGING_DEPLOYMENT_CHECKLIST.md`](./STAGING_DEPLOYMENT_CHECKLIST.md): 정적 검사, 환경변수, Compose 배포, 배포 후 검증
- [`GITEA_VARIABLES.md`](./GITEA_VARIABLES.md): Gitea Actions 변수/Secret 및 스테이징 매핑
- [`GUIDE.md`](./GUIDE.md): S3/S3 호환 스토리지와 Webhook 관련 기본 가이드
### API·SSOT·기능 작업
- [`api-packaging-tasks.md`](<./docs/api-packaging-tasks.md>): 외부 패키지용 API 경계, Swagger, DTO, 버전 정책
- [`ssot-feedback-rearchitecture-tasks.md`](<./docs/ssot-feedback-rearchitecture-tasks.md>): ABC DB를 피드백 SSOT로 통일한 단계별 작업
- [`qna-platform-prototype-2-feedback-tasks.md`](<./docs/qna-platform-prototype-2-feedback-tasks.md>): Q&A 관리자 콘솔 기능과 API 작업 이력
### 통합 아키텍처
- [`architecture_secretary_sso_components_v2.md`](<./docs/architecture_secretary_sso_components_v2.md>): 현재 통합 구조를 설명하는 우선 참고 문서
- [`architecture_secretary_sso_role_access.md`](<./docs/architecture_secretary_sso_role_access.md>): 역할, 테넌트, 로그인 후 접근 분기
- [`architecture_secretary_sso_user_scenarios.md`](<./docs/architecture_secretary_sso_user_scenarios.md>): 사용자 유형별 업무 시나리오
- [`architecture.md`](<./docs/architecture.md>): 초기 통합 지원 플랫폼 설계
- [`architecture_secretary_sso_components.md`](<./docs/architecture_secretary_sso_components.md>): 통합 컴포넌트 설계 초안
### SSO
- [`BARON-SSO server-side-app guide.md`](<./docs/BARON-SSO server-side-app guide.md>): 서버 애플리케이션의 BARON-SSO 연동 예시
- [`Back-Channel Logout.md`](<./docs/Back-Channel Logout.md>): Back-Channel Logout 처리 순서와 구현 위치
- [`architecture_secretary_sso_setup_tasks_v2.md`](<./docs/architecture_secretary_sso_setup_tasks_v2.md>): 최신 SSO 연계 셋업 및 남은 작업
### 데이터 이관·통합 대시보드
- [`egbim_to_secretary_staging_migration_tasks.md`](<./docs/egbim_to_secretary_staging_migration_tasks.md>): EGBIM/Secretary 데이터 이관 범위와 검증
- [`multi-project-admin-dashboard-design.md`](<./docs/multi-project-admin-dashboard-design.md>): 다중 프로젝트 관리자 통합 대시보드 설계
### 이전 버전·참고 문서
다음 문서는 앞선 설계 버전 또는 중복된 셋업 문서입니다. 현재 구현과 충돌할 경우 코드와 위의 v2/SSOT 문서를 우선합니다.
- [`architecture_secretary.md`](<./docs/architecture_secretary.md>)
- [`architecture_secretary_sso.md`](<./docs/architecture_secretary_sso.md>)
- [`architecture_secretary_sso_setup_tasks.md`](<./docs/architecture_secretary_sso_setup_tasks.md>)
문서의 작업 완료 표시는 당시 기준의 기록입니다. 배포 전에는 반드시 현재 코드, Compose 파일, 환경변수와 함께 대조합니다.
## 11. 저장소 구조
```text
apps/
api/ NestJS ABC API
web/ Next.js Web/BFF
secretary-api/ FastAPI Secretary API
e2e/ Playwright E2E
docs/ Docusaurus 기반 일반 제품 문서
docs/ 프로젝트 설계·작업·운영 보조 문서
docker/ 로컬/스테이징 Compose 및 Dockerfile
scripts/ 로컬 실행·이관·개발 보조 스크립트
uploads/ 로컬 첨부파일 마운트 경로
```
기여 방법은 [`CONTRIBUTING.md`](./CONTRIBUTING.md), 라이선스는 [`LICENSE`](./LICENSE)를 확인합니다.
+180
View File
@@ -0,0 +1,180 @@
# 스테이징 배포 체크리스트
현재 로컬 변경사항을 스테이징 서버에 배포한 뒤 테스트하기 위한 절차입니다.
## 1. 배포 전 로컬 확인
E2E 테스트는 배포 후 진행합니다. 배포 전에는 정적 검사와 빌드만 확인합니다.
```bash
git status
git diff --stat
pnpm lint
pnpm typecheck
pnpm build
```
빌드가 성공하면 변경사항을 커밋하고 스테이징 배포 브랜치에 푸시합니다.
```bash
git add .
git commit -m "Update feedback workflow"
git push origin <staging-branch>
```
`.env` 파일, 비밀번호, API 키, E2E 결과 파일은 커밋하지 않습니다.
## 2. 스테이징 환경변수 준비
스테이징 서버에 `.env.staging` 파일을 준비하고 다음 항목을 설정합니다.
- `NEXT_PUBLIC_API_BASE_URL`
- `ADMIN_WEB_URL`
- `BASE_URL`
- `JWT_SECRET`
- `MASTER_API_KEY`
- `SECRETARY_ABC_API_KEY`
- SSO issuer, client ID, client secret
- Gitea API URL 및 token
- SMTP 설정
- OpenSearch 설정
- 최초 배포 시 `AUTO_MIGRATION=true`
웹 환경변수는 Docker 이미지 빌드 시 `apps/web/.env.build`에서 사용됩니다.
```text
apps/web/.env.build
```
스테이징 API 주소가 설정되어 있어야 하며, `localhost` 또는 `127.0.0.1` 주소가 남아 있으면 안 됩니다.
## 3. 스테이징 서버에서 코드 갱신
```bash
git pull origin <staging-branch>
```
배포 전에 Compose 설정을 검증합니다.
```bash
docker compose \
--env-file .env.staging \
-f docker/docker-compose.prod.yml config
```
## 4. 스테이징 배포
```bash
docker compose \
--env-file .env.staging \
-f docker/docker-compose.prod.yml \
up -d --build
```
컨테이너 상태와 로그를 확인합니다.
```bash
docker compose \
--env-file .env.staging \
-f docker/docker-compose.prod.yml ps
docker compose \
--env-file .env.staging \
-f docker/docker-compose.prod.yml \
logs --tail=200 api secretary-api web
```
기존 스테이징 데이터가 있다면 다음 명령은 실행하지 않습니다.
```bash
docker compose down -v
```
`-v` 옵션은 데이터베이스 볼륨을 삭제할 수 있습니다. 기존 데이터가 있다면 배포 전에 DB 백업도 진행합니다.
## 5. 배포 후 테스트 순서
### 로그인 및 권한
- 관리자 SSO 로그인 및 callback URL 확인
- 관리자 계정이 콘솔/대시보드로 이동하는지 확인
- 일반 사용자 계정이 피드백 리스트로 이동하는지 확인
- 브라우저 콘솔과 Network에 401/500 오류가 없는지 확인
### 피드백 목록
- 리스트에 10개씩 표시되는지 확인
- 첫 페이지, 다음 페이지, 마지막 페이지 이동 확인
- ID, 제목, 내용, 이슈, 상태, Created, Updated 순서 확인
- 정렬 기능 확인
- 리스트/칸반 전환 확인
- 상태별 조회 확인
- 중요도 표시와 중요도별 배경색 확인
### 피드백 상태 및 상세
- 피드백 상태가 6단계로 표시되는지 확인
- 칸반에서 드래그하여 상태 변경되는지 확인
- 드래그 중 카드가 마우스를 따라가는 모션 확인
- 상태별 배경색 확인
- 피드백 상태와 이슈 상태가 독립적으로 변경되는지 확인
- IP 주소와 MAC 주소가 상세 페이지에 표시되는지 확인
- 수정 페이지에서 IP 주소와 MAC 주소를 수정할 수 있는지 확인
- 입력 항목의 툴팁 안내문구 확인
### 내부 메모 및 댓글
- 내부 메모가 관리자 화면에서만 표시되는지 확인
- 내부 메모 저장 시 외부 댓글이 동시에 등록되지 않는지 확인
- 외부 댓글 등록 시 내부 메모가 중복 생성되지 않는지 확인
### 이슈 및 Gitea 연동
- 피드백에서 이슈를 연결할 수 있는지 확인
- 하나의 이슈에 여러 피드백을 연결할 수 있는지 확인
- 추가로 연결한 피드백도 Gitea 이슈에 반영되는지 확인
- 이슈 연결 후 이슈 관리자를 지정할 수 있는지 확인
- 이슈 관리자 목록이 설정 페이지의 등록 목록과 일치하는지 확인
- 이슈 상태는 Gitea 처리 기준으로 독립적으로 변경되는지 확인
## 6. 문제 발생 시 확인할 로그
```bash
docker compose \
--env-file .env.staging \
-f docker/docker-compose.prod.yml \
logs -f api
docker compose \
--env-file .env.staging \
-f docker/docker-compose.prod.yml \
logs -f secretary-api
docker compose \
--env-file .env.staging \
-f docker/docker-compose.prod.yml \
logs -f web
```
브라우저에서는 다음을 함께 확인합니다.
- Console 오류
- Network 요청 URL
- 응답 상태 코드
- 401 Unauthorized 여부
- API callback 및 redirect 주소
- Gitea, SMTP, OpenSearch 연결 오류
## 7. 롤백 시 주의사항
- 이전 정상 커밋 또는 태그를 유지합니다.
- DB 백업을 먼저 확보합니다.
- 애플리케이션 롤백 시에도 DB 볼륨은 삭제하지 않습니다.
- 마이그레이션이 포함된 배포는 애플리케이션만 무조건 이전 버전으로 되돌리지 않습니다.
현재 로컬 서버는 `pnpm dev:local`로 실행 중이며, 스테이징 배포와는 별개입니다. 스테이징 배포는 로컬 작업 디렉터리에서 직접 실행하지 말고, 커밋 후 스테이징 서버에서 코드를 갱신한 뒤 진행합니다.
배포 기준 Compose 파일:
- `docker/docker-compose.prod.yml`
- `docker/web.dockerfile`
+76
View File
@@ -0,0 +1,76 @@
# 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
# ADMIN_CANDIDATE_EMAILS=cyhan@samaneng.com,hsmoon@hanmaceng.co.kr,hikim2@samaneng.com,thlee3@samaneng.com
# 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_ENABLED=true # set false for SSO-only staging
SMTP_HOST=localhost # required
SMTP_PORT=25
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
GITEA_API_URL=https://gitea.hmac.kr/api/v1
GITEA_API_TOKEN=
# Jira Cloud status synchronization
JIRA_API_EMAIL=
JIRA_API_TOKEN=
JIRA_WEBHOOK_SECRET=
JIRA_ISSUE_TYPE=Task
# 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
# ABC Webhook -> notification receiver (disabled by default)
# ABC_WEBHOOK_ENABLED=false
# ABC_WEBHOOK_TOKEN=
# ABC_WEBHOOK_SIGNING_SECRET=
# ABC_WEBHOOK_ALLOWED_PROJECT_IDS=1,2
# ABC_WEBHOOK_CLOCK_SKEW_SECONDS=300
# ABC_WEBHOOK_MAX_BODY_BYTES=262144
# NAVER WORKS Bot API (disabled by default)
# NAVER_WORKS_ENABLED=false
# NAVER_WORKS_API_BASE_URL=https://www.worksapis.com/v1.0
# NAVER_WORKS_AUTH_URL=https://auth.worksmobile.com/oauth2/v2.0/token
# NAVER_WORKS_ACCESS_TOKEN=
# NAVER_WORKS_BOT_ID=
# NAVER_WORKS_DEFAULT_ROOM_ID=
# NAVER_WORKS_CLIENT_ID=
# NAVER_WORKS_CLIENT_SECRET=
# NAVER_WORKS_SERVICE_ACCOUNT=
# NAVER_WORKS_PRIVATE_KEY=
# NAVER_WORKS_SCOPE=bot.message bot user.email.read
# NAVER_WORKS_MAX_RETRIES=3
# NAVER_WORKS_MAX_MESSAGE_LENGTH=1000
+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:"
}
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
import { Request } from 'express';
import { ClsModule } from 'nestjs-cls';
import { LoggerModule } from 'nestjs-pino';
import pino from 'pino';
import { appConfig, appConfigSchema } from './configs/app.config';
import { jwtConfig, jwtConfigSchema } from './configs/jwt.config';
import {
MailerConfigModule,
OpensearchConfigModule,
TypeOrmConfigModule,
} from './configs/modules';
import { mysqlConfig, mysqlConfigSchema } from './configs/mysql.config';
import {
opensearchConfig,
opensearchConfigSchema,
} from './configs/opensearch.config';
import { createOtelLogTransport } from './configs/otel-log.config';
import { smtpConfig, smtpConfigSchema } from './configs/smtp.config';
import { AuthModule } from './domains/admin/auth/auth.module';
import { ChannelModule } from './domains/admin/channel/channel/channel.module';
import { FieldModule } from './domains/admin/channel/field/field.module';
import { OptionModule } from './domains/admin/channel/option/option.module';
import { DashboardModule } from './domains/admin/dashboard/dashboard.module';
import { FeedbackModule } from './domains/admin/feedback/feedback.module';
import { HistoryModule } from './domains/admin/history/history.module';
import { AIModule } from './domains/admin/project/ai/ai.module';
import { ApiKeyModule } from './domains/admin/project/api-key/api-key.module';
import { CategoryModule } from './domains/admin/project/category/category.module';
import { IssueTrackerModule } from './domains/admin/project/issue-tracker/issue-tracker.module';
import { IssueModule } from './domains/admin/project/issue/issue.module';
import { MemberModule } from './domains/admin/project/member/member.module';
import { ProjectModule } from './domains/admin/project/project/project.module';
import { RoleModule } from './domains/admin/project/role/role.module';
import { WebhookModule } from './domains/admin/project/webhook/webhook.module';
import { FeedbackIssueStatisticsModule } from './domains/admin/statistics/feedback-issue/feedback-issue-statistics.module';
import { FeedbackStatisticsModule } from './domains/admin/statistics/feedback/feedback-statistics.module';
import { IssueStatisticsModule } from './domains/admin/statistics/issue/issue-statistics.module';
import { TenantModule } from './domains/admin/tenant/tenant.module';
import { UserModule } from './domains/admin/user/user.module';
import { APIModule } from './domains/api/api.module';
import { HealthModule } from './domains/operation/health/health.module';
import { MigrationModule } from './domains/operation/migration/migration.module';
import { SchedulerLockModule } from './domains/operation/scheduler-lock/scheduler-lock.module';
export const domainModules = [
AuthModule,
ChannelModule,
FieldModule,
OptionModule,
FeedbackModule,
DashboardModule,
CategoryModule,
HealthModule,
MigrationModule,
ApiKeyModule,
IssueTrackerModule,
IssueModule,
ProjectModule,
RoleModule,
TenantModule,
UserModule,
MemberModule,
HistoryModule,
WebhookModule,
FeedbackStatisticsModule,
IssueStatisticsModule,
FeedbackIssueStatisticsModule,
APIModule,
SchedulerLockModule,
AIModule,
] as (typeof AuthModule)[];
@Module({
imports: [
HttpModule.register({ global: true, timeout: 5000, maxRedirects: 5 }),
TypeOrmConfigModule,
OpensearchConfigModule,
MailerConfigModule,
PrometheusModule.register(),
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, opensearchConfig, smtpConfig, jwtConfig, mysqlConfig],
validationSchema: appConfigSchema
.concat(jwtConfigSchema)
.concat(mysqlConfigSchema)
.concat(smtpConfigSchema)
.concat(opensearchConfigSchema),
validationOptions: { abortEarly: true },
}),
LoggerModule.forRootAsync({
useFactory: () => {
const transport: pino.TransportMultiOptions = {
targets: [
{ target: 'pino-pretty', options: { singleLine: true } },
createOtelLogTransport(),
],
};
return {
pinoHttp: {
transport,
autoLogging: {
ignore: (req: Request) => req.originalUrl === '/api/health',
},
customLogLevel: (req, res, err) => {
if (process.env.NODE_ENV === 'test') {
return 'silent';
}
if (res.statusCode === 401) {
return 'silent';
}
if (res.statusCode >= 400 && res.statusCode < 500) {
return 'warn';
} else if (res.statusCode >= 500) {
return 'error';
} else if (err != null) {
return 'error';
}
return 'info';
},
},
};
},
}),
ClsModule.forRoot({
global: true,
middleware: { mount: true },
}),
ScheduleModule.forRoot(),
EventEmitterModule.forRoot(),
...domainModules,
],
})
export class AppModule {}
@@ -0,0 +1,40 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { Type } from '@nestjs/common';
import { applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
import { PaginationResponseDto } from '../dtos/pagination-response.dto';
export const ApiOkResponsePagination = <Dto extends Type<unknown>>(dto: Dto) =>
applyDecorators(
ApiExtraModels(PaginationResponseDto, dto),
ApiOkResponse({
schema: {
allOf: [
{ $ref: getSchemaPath(PaginationResponseDto) },
{
properties: {
items: {
type: 'array',
items: { $ref: getSchemaPath(dto) },
},
},
},
],
},
}),
);
@@ -0,0 +1,34 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { applyDecorators } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiForbiddenResponse,
ApiInternalServerErrorResponse,
ApiNotFoundResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { ApiErrorResponseDto } from '../dtos';
export const ApiStandardErrorResponses = () =>
applyDecorators(
ApiBadRequestResponse({ type: ApiErrorResponseDto }),
ApiUnauthorizedResponse({ type: ApiErrorResponseDto }),
ApiForbiddenResponse({ type: ApiErrorResponseDto }),
ApiNotFoundResponse({ type: ApiErrorResponseDto }),
ApiInternalServerErrorResponse({ type: ApiErrorResponseDto }),
);
@@ -0,0 +1,74 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { IsString } from 'class-validator';
import DtoValidator from './dto-validator';
class Dto {
@IsString()
str: string;
}
class TestClass {
@DtoValidator()
noParam() {
return;
}
@DtoValidator()
dtoParam(_dto: Dto) {
return;
}
@DtoValidator()
dtosParam(_dtos: Dto[]) {
return;
}
@DtoValidator()
compositionParam(_a: any, _dtos: Dto) {
return;
}
}
describe('dto validator', () => {
let instance: TestClass;
beforeEach(() => {
instance = new TestClass();
});
it('method call with no params', () => {
instance.noParam();
});
it('method call with no params', () => {
const dto = new Dto();
dto.str = 'test';
instance.dtoParam(dto);
const dto2 = new Dto();
void expect(instance.dtoParam(dto2)).rejects.toThrow();
});
it('method call with no params', () => {
const dto = new Dto();
dto.str = 'test';
instance.dtosParam([dto]);
const dto2 = new Dto();
void expect(instance.dtosParam([dto2])).rejects.toThrow();
});
it('method call with no params', () => {
const dto = new Dto();
dto.str = '123';
instance.compositionParam([1], dto);
});
});
@@ -0,0 +1,56 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { InternalServerErrorException } from '@nestjs/common';
import type { ValidationError } from 'class-validator';
import { validate } from 'class-validator';
type Method = (...args: object[]) => any;
const DtoValidator =
() =>
(
target: unknown,
propName: string,
descriptor: TypedPropertyDescriptor<any>,
) => {
const methodRef = descriptor.value as Method;
descriptor.value = async function (...args: object[]): Promise<any> {
for (const arg of args) {
let errors: ValidationError[] = [];
if (!Array.isArray(arg) && typeof arg === 'object') {
errors = await validate(arg);
} else if (
Array.isArray(arg) &&
arg.length > 0 &&
typeof arg[0] === 'object'
) {
errors = (
await Promise.all(
arg.map(async (item: object) => await validate(item)),
)
).flat();
}
if (errors.length > 0) {
throw new InternalServerErrorException(errors);
}
}
return (await methodRef.call(this, ...args)) as object;
};
return descriptor;
};
export default DtoValidator;
+18
View File
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { default as DtoValidator } from './dto-validator';
export { ApiOkResponsePagination } from './api-ok-response-pagination.decorator';
export { ApiStandardErrorResponses } from './api-standard-error-responses.decorator';
@@ -0,0 +1,70 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { faker } from '@faker-js/faker';
import { ValidationError, Validator } from 'class-validator';
import { IsPassword } from './is-password';
class IsPasswordTest {
@IsPassword()
password: any;
}
describe('IsPassword decorator', () => {
it('', () => {
const instance = new IsPasswordTest();
instance.password = faker.string.sample(
faker.number.int({ min: 8, max: 15 }),
);
const validator = new Validator();
void validator.validate(instance).then((errors) => {
expect(errors).toHaveLength(0);
});
});
it('minLength', () => {
const instance = new IsPasswordTest();
instance.password = faker.string.sample(
faker.number.int({ min: 0, max: 7 }),
);
const validator = new Validator();
void validator.validate(instance).then((errors: ValidationError[]) => {
expect(errors.length).toEqual(1);
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('minLength');
});
});
it('isString', () => {
const instance = new IsPasswordTest();
instance.password = faker.number.int({ min: 10000000, max: 99999999 });
const validator = new Validator();
void validator.validate(instance).then((errors: ValidationError[]) => {
expect(errors.length).toEqual(1);
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
});
});
it('isString, minLength', () => {
const instance = new IsPasswordTest();
instance.password = faker.number.int({ min: 0, max: 9999999 });
const validator = new Validator();
void validator.validate(instance).then((errors: ValidationError[]) => {
expect(errors.length).toEqual(1);
expect(Object.keys(errors[0].constraints ?? {})[0]).toEqual('isString');
expect(Object.keys(errors[0].constraints ?? {})[1]).toEqual('minLength');
});
});
});
@@ -0,0 +1,21 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { applyDecorators } from '@nestjs/common';
import { IsString, MinLength } from 'class-validator';
export function IsPassword() {
return applyDecorators(IsString(), MinLength(8));
}
@@ -0,0 +1,45 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ApiErrorResponseDto {
@ApiProperty({ example: 'BAD_REQUEST' })
code: string;
@ApiProperty({
oneOf: [
{ type: 'string', example: 'Invalid channel id' },
{
type: 'array',
items: { type: 'string' },
example: ['title should not be empty'],
},
],
})
message: string | string[];
@ApiProperty({ example: 'Bad Request' })
error: string;
@ApiProperty({ example: 400 })
statusCode: number;
@ApiProperty({ example: '/api/admin/projects/1/channels/1/feedbacks' })
path: string;
@ApiPropertyOptional({ type: Object, example: { field: 'email' } })
details?: Record<string, unknown>;
}
@@ -0,0 +1,144 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
const dynamicFieldValueSchema = {
oneOf: [
{ type: 'string' },
{ type: 'number' },
{ type: 'array', items: { type: 'string' } },
{ type: 'null' },
],
description:
'The value type is determined by the channel field format. Select and date fields may be null.',
};
export const DYNAMIC_FEEDBACK_REQUEST_SCHEMA = {
type: 'object',
description:
'Dynamic feedback fields. Use GET /projects/{projectId}/channels/{channelId}/fields to discover the accepted keys, formats, and select options.',
additionalProperties: dynamicFieldValueSchema,
properties: {
title: { type: 'string', example: 'Feedback title' },
contents: { type: 'string', example: 'Feedback contents' },
Category: { type: 'string', example: 'ERROR_QNA' },
IP: { type: 'string', example: '192.168.0.10' },
MAC_address: { type: 'string', example: '00:1A:2B:3C:4D:5E' },
issueNames: {
type: 'array',
items: { type: 'string' },
description:
'Optional issue names to connect. This control field is used for issue linking and is not saved as a feedback field.',
example: ['Login error'],
},
},
example: {
title: 'Feedback title',
contents: 'Feedback contents',
Category: 'ERROR_QNA',
IP: '192.168.0.10',
MAC_address: '00:1A:2B:3C:4D:5E',
issueNames: ['Login error'],
},
};
export const DYNAMIC_FEEDBACK_MULTIPART_SCHEMA = {
type: 'object',
description:
'Multipart feedback input. Non-file parts use the channel field keys; the images field accepts repeated binary files of any type.',
additionalProperties: { type: 'string' },
properties: {
title: { type: 'string', example: 'Feedback title' },
contents: { type: 'string', example: 'Feedback contents' },
issueNames: {
type: 'string',
description: 'JSON array or repeated value depending on the client.',
example: '["Login error"]',
},
images: {
type: 'array',
items: { type: 'string', format: 'binary' },
description: 'Repeated file attachments. Any file type is accepted within the upload limits.',
},
},
};
export const DYNAMIC_FEEDBACK_ITEM_SCHEMA = {
type: 'object',
description:
'Feedback item. Channel field values are returned as top-level dynamic properties.',
additionalProperties: dynamicFieldValueSchema,
properties: {
id: { type: 'number', example: 34 },
createdAt: {
type: 'string',
format: 'date-time',
example: '2026-08-25T09:00:00.000Z',
},
updatedAt: {
type: 'string',
format: 'date-time',
example: '2026-08-25T09:30:00.000Z',
},
issues: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'number', example: 13 },
name: { type: 'string', example: 'Login error' },
status: { type: 'string', example: 'IN_PROGRESS' },
},
},
},
},
};
export const DYNAMIC_FEEDBACK_PAGINATION_SCHEMA = {
type: 'object',
properties: {
items: {
type: 'array',
items: DYNAMIC_FEEDBACK_ITEM_SCHEMA,
},
meta: {
type: 'object',
properties: {
itemCount: { type: 'number', example: 10 },
totalItems: { type: 'number', example: 35 },
itemsPerPage: { type: 'number', example: 10 },
currentPage: { type: 'number', example: 1 },
totalPages: { type: 'number', example: 4 },
},
},
},
};
export const DYNAMIC_FEEDBACK_QUERY_VALUE_SCHEMA = {
oneOf: [
{ type: 'string' },
{ type: 'number' },
{
type: 'array',
items: { oneOf: [{ type: 'string' }, { type: 'number' }] },
},
{
type: 'object',
properties: {
gte: { type: 'string', example: '2026-08-01' },
lt: { type: 'string', example: '2026-09-01' },
},
},
],
};
+21
View File
@@ -0,0 +1,21 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { PaginationDto } from './pagination.dto';
export { PaginationRequestDto } from './pagination-request.dto';
export { PaginationResponseDto } from './pagination-response.dto';
export { TimeRange } from './time-range.dto';
export { ApiErrorResponseDto } from './api-error-response.dto';
export * from './dynamic-feedback.dto';
@@ -0,0 +1,45 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNumber, IsOptional, Min } from 'class-validator';
import { toNumber } from '@/common/helper/cast.helper';
export class PaginationRequestDto {
@Transform(({ value }: { value: string }) =>
toNumber(value, { default: 10, min: 1 }),
)
@ApiProperty({ required: false, minimum: 1, default: 10, example: 10 })
@IsOptional()
@IsNumber()
@Min(1)
limit: number;
@Transform(({ value }: { value: string }) =>
toNumber(value, { default: 1, min: 1 }),
)
@ApiProperty({ required: false, minimum: 1, default: 1, example: 1 })
@IsOptional()
@IsNumber()
@Min(1)
page: number;
constructor(limit = 10, page = 1) {
this.limit = limit;
this.page = page;
}
}
@@ -0,0 +1,49 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { ApiProperty } from '@nestjs/swagger';
import { Expose, Type } from 'class-transformer';
import type { IPaginationMeta, Pagination } from 'nestjs-typeorm-paginate';
class PaginationMetaDto implements IPaginationMeta {
@ApiProperty({ example: 10 })
@Expose()
itemCount: number;
@ApiProperty({ example: 100 })
@Expose()
totalItems?: number;
@ApiProperty({ example: 10 })
@Expose()
itemsPerPage: number;
@ApiProperty({ example: 10 })
@Expose()
totalPages?: number;
@ApiProperty({ example: 1 })
@Expose()
currentPage: number;
}
export abstract class PaginationResponseDto<T> implements Pagination<T> {
@ApiProperty()
@Expose()
@Type(() => PaginationMetaDto)
meta: PaginationMetaDto;
abstract items: T[];
}
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export class PaginationDto {
page: number;
limit: number;
}
@@ -0,0 +1,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { ApiProperty } from '@nestjs/swagger';
export class TimeRange {
@ApiProperty({ name: 'gte (UTC)' })
gte: string;
@ApiProperty({ name: 'lt (UTC)' })
lt: string;
}
@@ -0,0 +1,52 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { DateTime } from 'luxon';
import {
BeforeInsert,
BeforeUpdate,
CreateDateColumn,
DeleteDateColumn,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export abstract class CommonEntity {
@PrimaryGeneratedColumn('increment')
id: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@DeleteDateColumn()
deletedAt: Date;
@BeforeInsert()
beforeInsertHook() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!this.createdAt) {
this.createdAt = DateTime.utc().toJSDate();
}
this.updatedAt = DateTime.utc().toJSDate();
}
@BeforeUpdate()
beforeUpdateHook() {
this.updatedAt = DateTime.utc().toJSDate();
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { CommonEntity } from './common.entity';
@@ -0,0 +1,21 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum AIPromptStatusEnum {
success = 'success',
error = 'error',
loading = 'loading',
}
@@ -0,0 +1,20 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum AIProvidersEnum {
OPEN_AI = 'OPEN_AI',
GEMINI = 'GEMINI',
}
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum EventStatusEnum {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
}
@@ -0,0 +1,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum EventTypeEnum {
FEEDBACK_CREATION = 'FEEDBACK_CREATION',
ISSUE_CREATION = 'ISSUE_CREATION',
ISSUE_STATUS_CHANGE = 'ISSUE_STATUS_CHANGE',
ISSUE_ADDITION = 'ISSUE_ADDITION',
FEEDBACK_STATUS_CHANGE = 'FEEDBACK_STATUS_CHANGE',
FEEDBACK_COMMENT_CREATION = 'FEEDBACK_COMMENT_CREATION',
}
@@ -0,0 +1,21 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum FeedbackPriorityEnum {
LOW = 'LOW',
MEDIUM = 'MEDIUM',
HIGH = 'HIGH',
CRITICAL = 'CRITICAL',
}
@@ -0,0 +1,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum FeedbackStatusEnum {
INIT = 'INIT',
ON_REVIEW = 'ON_REVIEW',
DETAILED_REVIEW = 'DETAILED_REVIEW',
IN_PROGRESS = 'IN_PROGRESS',
RESOLVED = 'RESOLVED',
PENDING = 'PENDING',
}
@@ -0,0 +1,29 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum FieldFormatEnum {
text = 'text',
keyword = 'keyword',
number = 'number',
select = 'select',
multiSelect = 'multiSelect',
date = 'date',
images = 'images',
aiField = 'aiField',
}
export function isSelectFieldFormat(type: FieldFormatEnum) {
return [FieldFormatEnum.select, FieldFormatEnum.multiSelect].includes(type);
}
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum FieldPropertyEnum {
READ_ONLY = 'READ_ONLY',
EDITABLE = 'EDITABLE',
}
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum FieldStatusEnum {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { FieldFormatEnum, isSelectFieldFormat } from './field-format.enum';
export { FieldPropertyEnum } from './field-property.enum';
export { FieldStatusEnum } from './field-status.enum';
export { IssueStatusEnum } from './issue-status.enum';
export { FeedbackStatusEnum } from './feedback-status.enum';
export { FeedbackPriorityEnum } from './feedback-priority.enum';
export { SortMethodEnum } from './sort-method.enum';
export { EventTypeEnum } from './event-type.enum';
export { EventStatusEnum } from './event-status.enum';
export { WebhookStatusEnum } from './webhook-status.enum';
export { QueryV2ConditionsEnum } from './query-v2-conditions.enum';
@@ -0,0 +1,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum IssueStatusEnum {
INIT = 'INIT',
ON_REVIEW = 'ON_REVIEW',
DETAILED_REVIEW = 'DETAILED_REVIEW',
IN_PROGRESS = 'IN_PROGRESS',
RESOLVED = 'RESOLVED',
PENDING = 'PENDING',
}
@@ -0,0 +1,20 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum QueryV2ConditionsEnum {
CONTAINS = 'CONTAINS',
IS = 'IS',
BETWEEN = 'BETWEEN',
}
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum SortMethodEnum {
ASC = 'ASC',
DESC = 'DESC',
}
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export enum WebhookStatusEnum {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE',
}
@@ -0,0 +1,329 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { ArgumentsHost } from '@nestjs/common';
import { HttpException, HttpStatus } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => {
let filter: HttpExceptionFilter;
let mockRequest: FastifyRequest;
let mockResponse: FastifyReply;
let mockArgumentsHost: ArgumentsHost;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [HttpExceptionFilter],
}).compile();
filter = module.get<HttpExceptionFilter>(HttpExceptionFilter);
// Mock FastifyRequest
mockRequest = {
url: '/test-endpoint',
method: 'GET',
headers: {},
query: {},
params: {},
body: {},
} as FastifyRequest;
// Mock FastifyReply
mockResponse = {
status: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
} as unknown as FastifyReply;
// Mock ArgumentsHost
mockArgumentsHost = {
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue(mockRequest),
getResponse: jest.fn().mockReturnValue(mockResponse),
}),
} as unknown as ArgumentsHost;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('catch', () => {
it('should handle string exception response', () => {
const exception = new HttpException(
'Test error message',
HttpStatus.BAD_REQUEST,
);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'BAD_REQUEST',
message: 'Test error message',
error: 'BAD_REQUEST',
statusCode: HttpStatus.BAD_REQUEST,
path: '/test-endpoint',
});
});
it('should handle object exception response', () => {
const exceptionResponse = {
message: 'Validation failed',
error: 'Bad Request',
statusCode: HttpStatus.BAD_REQUEST,
};
const exception = new HttpException(
exceptionResponse,
HttpStatus.BAD_REQUEST,
);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'BAD_REQUEST',
message: 'Validation failed',
error: 'Bad Request',
statusCode: HttpStatus.BAD_REQUEST,
path: '/test-endpoint',
});
});
it('should handle different HTTP status codes', () => {
const statusCodes = [
HttpStatus.UNAUTHORIZED,
HttpStatus.FORBIDDEN,
HttpStatus.NOT_FOUND,
HttpStatus.INTERNAL_SERVER_ERROR,
];
statusCodes.forEach((statusCode) => {
const exception = new HttpException('Test error', statusCode);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(statusCode);
expect(mockResponse.send).toHaveBeenCalledWith({
code: HttpStatus[statusCode],
message: 'Test error',
error: HttpStatus[statusCode],
statusCode,
path: '/test-endpoint',
});
});
});
it('should handle complex object exception response', () => {
const exceptionResponse = {
message: ['Email is required', 'Password is too short'],
error: 'Validation Error',
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
details: {
field: 'email',
value: '',
},
};
const exception = new HttpException(
exceptionResponse,
HttpStatus.UNPROCESSABLE_ENTITY,
);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(
HttpStatus.UNPROCESSABLE_ENTITY,
);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'VALIDATION_ERROR',
message: ['Email is required', 'Password is too short'],
error: 'Validation Error',
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
path: '/test-endpoint',
details: {
field: 'email',
value: '',
},
});
});
it('should handle empty string exception response', () => {
const exception = new HttpException('', HttpStatus.NO_CONTENT);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'NO_CONTENT',
message: '',
error: 'NO_CONTENT',
statusCode: HttpStatus.NO_CONTENT,
path: '/test-endpoint',
});
});
it('should handle null exception response', () => {
const exception = new HttpException(null as any, HttpStatus.NO_CONTENT);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'NO_CONTENT',
message: 'NO_CONTENT',
error: 'NO_CONTENT',
statusCode: HttpStatus.NO_CONTENT,
path: '/test-endpoint',
});
});
it('should handle undefined exception response', () => {
const exception = new HttpException(
undefined as any,
HttpStatus.NO_CONTENT,
);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NO_CONTENT);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'NO_CONTENT',
message: 'NO_CONTENT',
error: 'NO_CONTENT',
statusCode: HttpStatus.NO_CONTENT,
path: '/test-endpoint',
});
});
it('should handle different request URLs', () => {
const urls = [
'/api/users',
'/api/projects/123',
'/api/auth/login',
'/api/feedback?page=1&limit=10',
];
urls.forEach((url) => {
Object.assign(mockRequest, { url });
const exception = new HttpException(
'Test error',
HttpStatus.BAD_REQUEST,
);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'BAD_REQUEST',
message: 'Test error',
error: 'BAD_REQUEST',
statusCode: HttpStatus.BAD_REQUEST,
path: url,
});
});
});
it('should handle nested object exception response', () => {
const exceptionResponse = {
message: 'Complex error',
error: 'Internal Server Error',
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
nested: {
level1: {
level2: {
value: 'deep nested value',
},
},
},
};
const exception = new HttpException(
exceptionResponse,
HttpStatus.INTERNAL_SERVER_ERROR,
);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(
HttpStatus.INTERNAL_SERVER_ERROR,
);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'INTERNAL_SERVER_ERROR',
message: 'Complex error',
error: 'Internal Server Error',
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
path: '/test-endpoint',
details: {
nested: {
level1: {
level2: {
value: 'deep nested value',
},
},
},
},
});
});
it('should handle array exception response', () => {
const exceptionResponse = ['Error 1', 'Error 2', 'Error 3'];
const exception = new HttpException(
exceptionResponse,
HttpStatus.BAD_REQUEST,
);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'BAD_REQUEST',
message: ['Error 1', 'Error 2', 'Error 3'],
error: 'BAD_REQUEST',
statusCode: HttpStatus.BAD_REQUEST,
path: '/test-endpoint',
});
});
it('should handle boolean exception response', () => {
const exception = new HttpException(true as any, HttpStatus.OK);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'OK',
message: 'OK',
error: 'OK',
statusCode: HttpStatus.OK,
path: '/test-endpoint',
});
});
it('should handle number exception response', () => {
const exception = new HttpException(42 as any, HttpStatus.OK);
filter.catch(exception, mockArgumentsHost);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
expect(mockResponse.send).toHaveBeenCalledWith({
code: 'OK',
message: 'OK',
error: 'OK',
statusCode: HttpStatus.OK,
path: '/test-endpoint',
});
});
});
});
@@ -0,0 +1,87 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common';
import { Catch, HttpException, HttpStatus, Logger } from '@nestjs/common';
import type { FastifyReply, FastifyRequest } from 'fastify';
type ExceptionResponse = {
message?: string | string[];
error?: string;
statusCode?: number;
details?: unknown;
[key: string]: unknown;
};
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse();
this.logger.error({ statusCode, exceptionResponse });
const normalizedResponse: ExceptionResponse =
typeof exceptionResponse === 'string' ? { message: exceptionResponse }
: Array.isArray(exceptionResponse) ?
{ message: exceptionResponse.map((item) => String(item)) }
: ((exceptionResponse as ExceptionResponse | null) ?? {});
const {
message: responseMessage,
error: responseError,
statusCode: _responseStatusCode,
details: responseDetails,
...extraDetails
} = normalizedResponse;
const error = responseError ?? this.getHttpErrorName(statusCode);
const message = responseMessage ?? this.getHttpErrorName(statusCode);
const details =
responseDetails ??
(Object.keys(extraDetails).length > 0 ? extraDetails : undefined);
void response.status(statusCode).send({
code: this.toErrorCode(error, statusCode),
message,
error,
statusCode,
path: request.url,
...(details === undefined ?
{}
: {
details,
}),
});
}
private getHttpErrorName(statusCode: number): string {
return HttpStatus[statusCode] ?? 'HTTP_ERROR';
}
private toErrorCode(error: string, statusCode: number): string {
const code = error
.trim()
.replace(/([a-z])([A-Z])/g, '$1_$2')
.replace(/[^a-zA-Z0-9]+/g, '_')
.replace(/^_|_$/g, '')
.toUpperCase();
return code || this.getHttpErrorName(statusCode);
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { HttpExceptionFilter } from './http-exception.filter';
+58
View File
@@ -0,0 +1,58 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
interface ToNumberOptions {
default?: number;
min?: number;
max?: number;
}
export function toLowerCase(value: string): string {
return value.toLowerCase();
}
export function trim(value: string): string {
return value.trim();
}
export function toDate(value: string): Date {
return new Date(value);
}
export function toBoolean(value: string): boolean {
value = value.toLowerCase();
return value === 'true' || value === '1' ? true : false;
}
export function toNumber(value: string, opts: ToNumberOptions = {}): number {
let newValue: number = Number.parseInt(value || String(opts.default), 10);
if (Number.isNaN(newValue)) {
newValue = opts.default ?? 0;
}
if (opts.min) {
if (newValue < opts.min) {
newValue = opts.min;
}
if (opts.max && newValue > opts.max) {
newValue = opts.max;
}
}
return newValue;
}
@@ -0,0 +1,43 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { paginate } from 'nestjs-typeorm-paginate';
import type {
IPaginationMeta,
IPaginationOptions,
} from 'nestjs-typeorm-paginate';
import type { FindManyOptions, SelectQueryBuilder } from 'typeorm';
export async function paginateHelper(
queryBuilder: SelectQueryBuilder<any>,
findOptions: FindManyOptions<any>,
options: IPaginationOptions,
) {
const totalItems = await queryBuilder
.clone()
.setFindOptions(findOptions)
.getCount();
return await paginate(queryBuilder.setFindOptions(findOptions), {
...options,
countQueries: false,
metaTransformer: (meta: IPaginationMeta): IPaginationMeta => {
return {
...meta,
totalItems,
totalPages: Math.ceil(totalItems / meta.itemsPerPage),
};
},
});
}
@@ -0,0 +1,20 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export class CreateDataDto {
id?: string;
index: string;
data: Record<string, any>;
}
@@ -0,0 +1,18 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export class CreateIndexDto {
index: string;
}
@@ -0,0 +1,19 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export class DeleteBulkDataDto {
ids: number[];
index: string;
}
@@ -0,0 +1,23 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { PaginationDto } from '@/common/dtos';
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
export class GetDataDto extends PaginationDto {
index: string;
query: OsQueryDto;
sort: string[];
}
@@ -0,0 +1,22 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { CreateIndexDto } from './create-index.dto';
export { PutMappingsDto } from './put-mappings.dto';
export { CreateDataDto } from './create-data.dto';
export { GetDataDto } from './get-data.dto';
export { UpdateDataDto } from './update-data.dto';
export { DeleteBulkDataDto } from './delete-bulk-data.dto';
export { ScrollDto } from './scroll.dto';
@@ -0,0 +1,22 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { Property } from '@opensearch-project/opensearch/api/_types/_common.mapping';
export class PutMappingsDto {
index: string;
mappings: Record<string, Property>;
}
@@ -0,0 +1,24 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { OsQueryDto } from '@/domains/admin/feedback/dtos/os-query.dto';
export class ScrollDto {
index: string;
query: OsQueryDto;
sort: string[];
size: number;
scrollId: string | null;
}
@@ -0,0 +1,20 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export class UpdateDataDto {
id: string;
index: string;
data: Record<string, any>;
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { OpensearchRepository } from './opensearch.repository';
@@ -0,0 +1,27 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { BadRequestException } from '@nestjs/common';
import { ErrorCode } from '@ufb/shared';
export class LargeWindowException extends BadRequestException {
constructor(message: string) {
super({
code: ErrorCode.Opensearch.LargeWindow,
message,
});
}
}
@@ -0,0 +1,697 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import { faker } from '@faker-js/faker';
import {
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { Test } from '@nestjs/testing';
import type { Client } from '@opensearch-project/opensearch';
import type { TextProperty } from '@opensearch-project/opensearch/api/_types/_common.mapping';
import { getMockProvider } from '@/test-utils/util-functions';
import { CreateDataDto, PutMappingsDto } from './dtos';
import { OpensearchRepository } from './opensearch.repository';
const MockClient = {
indices: {
create: jest.fn(),
putAlias: jest.fn(),
exists: jest.fn(),
putMapping: jest.fn(),
getMapping: jest.fn(),
delete: jest.fn(),
},
index: jest.fn(),
search: jest.fn(),
scroll: jest.fn(),
update: jest.fn(),
deleteByQuery: jest.fn(),
count: jest.fn(),
};
const OpensearchRepositoryProviders = [
OpensearchRepository,
getMockProvider('OPENSEARCH_CLIENT', MockClient),
];
const COMPLICATE_JSON = {
KEY1: 'VALUE1',
KEY2: 'VALUE2',
};
const MAPPING_JSON = {
KEY1: {
type: 'text',
} as TextProperty,
};
describe('Opensearch Repository Test suite', () => {
let osRepo: OpensearchRepository;
let osClient: Client;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: OpensearchRepositoryProviders,
}).compile();
osRepo = module.get(OpensearchRepository);
osClient = module.get('OPENSEARCH_CLIENT');
});
describe('create index', () => {
it('positive case', async () => {
const index = faker.number.int().toString();
const indexName = 'channel_' + index;
jest.spyOn(osClient.indices, 'create');
jest.spyOn(osClient.indices, 'putAlias');
await osRepo.createIndex({ index });
expect(osClient.indices.create).toHaveBeenCalledTimes(1);
expect(osClient.indices.create).toHaveBeenCalledWith({
index: indexName,
body: {
settings: {
index: { max_ngram_diff: 1 },
analysis: {
analyzer: {
ngram_analyzer: {
filter: ['lowercase', 'asciifolding', 'cjk_width'],
tokenizer: 'ngram_tokenizer',
type: 'custom',
},
},
tokenizer: {
ngram_tokenizer: {
type: 'ngram',
min_gram: 1,
max_gram: 2,
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
},
},
},
},
},
});
expect(osClient.indices.putAlias).toHaveBeenCalledTimes(1);
expect(osClient.indices.putAlias).toHaveBeenCalledWith({
index: indexName,
name: index,
});
});
it('creating index handles errors', async () => {
const index = faker.number.int().toString();
const error = new Error('Index creation failed');
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
await expect(osRepo.createIndex({ index })).rejects.toThrow(
'Index creation failed',
);
});
it('creating index handles OpenSearch specific errors', async () => {
const index = faker.number.int().toString();
const error = {
meta: {
body: {
error: {
type: 'resource_already_exists_exception',
reason: 'index already exists',
},
},
},
};
jest.spyOn(osClient.indices, 'create').mockRejectedValue(error as never);
await expect(osRepo.createIndex({ index })).rejects.toEqual(error);
});
});
describe('putMappings', () => {
it('putting mappings succeeds with an existent index', async () => {
const dto = new PutMappingsDto();
dto.index = faker.number.int().toString();
dto.mappings = MAPPING_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 200 } as never);
jest.spyOn(osClient.indices, 'putMapping');
await osRepo.putMappings(dto);
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
expect(osClient.indices.putMapping).toHaveBeenCalledTimes(1);
expect(osClient.indices.putMapping).toHaveBeenCalledWith({
index: dto.index,
body: { properties: dto.mappings },
});
});
it('putting mappings fails with a nonexistent index', async () => {
const dto = new PutMappingsDto();
dto.index = faker.number.int().toString();
dto.mappings = MAPPING_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 404 } as never);
jest.spyOn(osClient.indices, 'putMapping');
await expect(osRepo.putMappings(dto)).rejects.toThrow(
new NotFoundException('index is not found'),
);
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
expect(osClient.indices.putMapping).not.toHaveBeenCalled();
});
it('putting mappings handles OpenSearch errors', async () => {
const dto = new PutMappingsDto();
dto.index = faker.number.int().toString();
dto.mappings = MAPPING_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 200 } as never);
const error = {
meta: {
body: {
error: {
type: 'illegal_argument_exception',
reason: 'mapping update failed',
},
},
},
};
jest
.spyOn(osClient.indices, 'putMapping')
.mockRejectedValue(error as never);
await expect(osRepo.putMappings(dto)).rejects.toEqual(error);
});
});
describe('createData', () => {
it('creating data succeeds with valid inputs', async () => {
const index = faker.number.int().toString();
const id = faker.number.int().toString();
const dto = new CreateDataDto();
dto.id = id;
dto.index = index;
dto.data = COMPLICATE_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 200 } as never);
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
body: {
['channel_' + index]: {
mappings: {
properties: dto.data,
},
},
},
} as never);
jest.spyOn(osClient, 'index').mockResolvedValue({
body: {
_id: dto.id,
},
} as never);
const response = await osRepo.createData(dto);
expect(response.id).toEqual(dto.id);
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
expect(osClient.index).toHaveBeenCalledTimes(1);
expect(osClient.index).toHaveBeenCalledWith({
id: dto.id,
index: 'channel_' + index,
body: dto.data,
refresh: true,
});
});
it('creating data fails with an invalid index', async () => {
const invalidIndex = faker.number.int().toString();
const id = faker.number.int().toString();
const dto = new CreateDataDto();
dto.id = id;
dto.index = invalidIndex;
dto.data = COMPLICATE_JSON;
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ body: false } as never);
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
body: {
['channel_' + faker.number.int().toString()]: {
mappings: {
properties: dto.data,
},
},
},
} as never);
jest.spyOn(osClient, 'index').mockResolvedValue({
body: {
_id: dto.id,
},
} as never);
await expect(osRepo.createData(dto)).rejects.toThrow(
new NotFoundException('index is not found'),
);
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
expect(osClient.indices.getMapping).not.toHaveBeenCalled();
expect(osClient.index).not.toHaveBeenCalled();
});
it('creating data fails with invalid data', async () => {
const index = faker.number.int().toString();
const id = faker.number.int().toString();
const data = COMPLICATE_JSON;
const dto = new CreateDataDto();
dto.id = id;
dto.index = index;
dto.data = {
...data,
invalidKey: 'invalidValue',
};
jest
.spyOn(osClient.indices, 'exists')
.mockResolvedValue({ statusCode: 200 } as never);
jest.spyOn(osClient.indices, 'getMapping').mockResolvedValue({
body: {
['channel_' + index]: {
mappings: {
properties: data,
},
},
},
} as never);
jest.spyOn(osClient, 'index').mockResolvedValue({
body: {
_id: dto.id,
},
} as never);
await expect(osRepo.createData(dto)).rejects.toThrow(
new InternalServerErrorException('error!!!'),
);
expect(osClient.indices.exists).toHaveBeenCalledTimes(1);
expect(osClient.indices.getMapping).toHaveBeenCalledTimes(1);
expect(osClient.index).not.toHaveBeenCalled();
});
});
describe('getData', () => {
it('getting data succeeds with valid inputs', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const sort = ['_id:desc'];
const limit = 10;
const page = 1;
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: {
hits: [
{ _source: { KEY1: 'VALUE1' } },
{ _source: { KEY2: 'VALUE2' } },
],
total: 2,
},
},
} as never);
const result = await osRepo.getData({ index, query, sort, limit, page });
expect(result.items).toHaveLength(2);
expect(result.total).toBe(2);
expect(osClient.search).toHaveBeenCalledWith({
index,
from: 0,
size: limit,
sort,
body: { query },
});
});
it('getting data with empty sort adds default sort', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const sort: string[] = [];
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: {
hits: [],
total: 0,
},
},
} as never);
await osRepo.getData({ index, query, sort, page: 1, limit: 100 });
expect(osClient.search).toHaveBeenCalledWith({
index,
from: 0,
size: 100,
sort: ['_id:desc'],
body: { query },
});
});
it('getting data handles large window exception', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const error = new Error('Result window is too large');
error.name = 'OpenSearchClientError';
jest.spyOn(osClient, 'search').mockRejectedValue(error as never);
await expect(
osRepo.getData({ index, query, sort: [], page: 1, limit: 100 }),
).rejects.toThrow('Result window is too large');
});
it('getting data handles total as object', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: {
hits: [],
total: { value: 100, relation: 'eq' },
},
},
} as never);
const result = await osRepo.getData({
index,
query,
sort: [],
page: 1,
limit: 100,
});
expect(result.total).toBe(100);
});
});
describe('scroll', () => {
it('scrolling with scrollId succeeds', async () => {
const scrollId = faker.string.alphanumeric(32);
const mockData = [{ KEY1: 'VALUE1' }, { KEY2: 'VALUE2' }];
jest.spyOn(osClient, 'scroll').mockResolvedValue({
body: {
hits: {
hits: mockData.map((data) => ({ _source: data })),
},
_scroll_id: scrollId,
},
} as never);
const result = await osRepo.scroll({
scrollId,
index: '',
size: 10,
query: { bool: { must: [{ term: { status: 'active' } }] } },
sort: [],
});
expect(result.data).toEqual(mockData);
expect(result.scrollId).toEqual(scrollId);
expect(osClient.scroll).toHaveBeenCalledWith({
scroll_id: scrollId,
scroll: '1m',
});
});
it('scrolling without scrollId performs initial search', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const sort = ['_id:desc'];
const size = 10;
const mockData = [{ KEY1: 'VALUE1' }];
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: {
hits: mockData.map((data) => ({ _source: data })),
},
_scroll_id: 'new_scroll_id',
},
} as never);
const result = await osRepo.scroll({
index,
query,
sort,
size,
scrollId: null,
});
expect(result.data).toEqual(mockData);
expect(result.scrollId).toEqual('new_scroll_id');
expect(osClient.search).toHaveBeenCalledWith({
index,
size,
sort,
body: { query },
scroll: '1m',
});
});
it('scrolling with empty sort adds default sort', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const sort: string[] = [];
jest.spyOn(osClient, 'search').mockResolvedValue({
body: {
hits: { hits: [] },
_scroll_id: 'scroll_id',
},
} as never);
await osRepo.scroll({ index, query, sort, size: 10, scrollId: null });
expect(osClient.search).toHaveBeenCalledWith({
index,
size: 10,
sort: ['_id:desc'],
body: { query },
scroll: '1m',
});
});
});
describe('updateData', () => {
it('updating data succeeds with valid inputs', async () => {
const index = faker.number.int().toString();
const id = faker.number.int().toString();
const updateData = { KEY1: 'UPDATED_VALUE' };
jest.spyOn(osClient, 'update').mockResolvedValue({
body: {
_id: id,
result: 'updated',
},
} as never);
await osRepo.updateData({ index, id, data: updateData });
expect(osClient.update).toHaveBeenCalledWith({
index,
id,
body: {
doc: updateData,
},
refresh: true,
retry_on_conflict: 5,
});
});
it('updating data handles errors', async () => {
const index = faker.number.int().toString();
const id = faker.number.int().toString();
const updateData = { KEY1: 'UPDATED_VALUE' };
const error = new Error('Update failed');
jest.spyOn(osClient, 'update').mockRejectedValue(error as never);
await expect(
osRepo.updateData({ index, id, data: updateData }),
).rejects.toThrow('Update failed');
});
});
describe('deleteBulkData', () => {
it('deleting bulk data succeeds with valid ids', async () => {
const index = faker.number.int().toString();
const ids = [faker.number.int(), faker.number.int()];
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
body: {
deleted: ids.length,
},
} as never);
await osRepo.deleteBulkData({ index, ids });
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
index,
body: { query: { terms: { _id: ids } } },
refresh: true,
});
});
it('deleting bulk data with empty ids array', async () => {
const index = faker.number.int().toString();
const ids: number[] = [];
jest.spyOn(osClient, 'deleteByQuery').mockResolvedValue({
body: {
deleted: 0,
},
} as never);
await osRepo.deleteBulkData({ index, ids });
expect(osClient.deleteByQuery).toHaveBeenCalledWith({
index,
body: { query: { terms: { _id: ids } } },
refresh: true,
});
});
});
describe('deleteIndex', () => {
it('deleting index succeeds with valid index', async () => {
const index = faker.number.int().toString();
const indexName = 'channel_' + index;
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
body: {
acknowledged: true,
},
} as never);
await osRepo.deleteIndex(index);
expect(osClient.indices.delete).toHaveBeenCalledWith({
index: indexName,
});
});
it('deleting index handles errors', async () => {
const index = faker.number.int().toString();
const error = new Error('Delete failed');
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
await expect(osRepo.deleteIndex(index)).rejects.toThrow('Delete failed');
});
});
describe('getTotal', () => {
it('getting total count succeeds with valid query', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
jest.spyOn(osClient, 'count').mockResolvedValue({
body: {
count: 100,
},
} as never);
const result = await osRepo.getTotal(index, query);
expect(result).toBe(100);
expect(osClient.count).toHaveBeenCalledWith({
index,
body: { query },
});
});
it('getting total count with complex query', async () => {
const index = faker.number.int().toString();
const query = {
bool: {
must: [
{ term: { status: 'active' } },
{ range: { created_at: { gte: '2023-01-01' } } },
],
},
};
jest.spyOn(osClient, 'count').mockResolvedValue({
body: {
count: 50,
},
} as never);
const result = await osRepo.getTotal(index, query);
expect(result).toBe(50);
expect(osClient.count).toHaveBeenCalledWith({
index,
body: { query },
});
});
it('getting total count handles errors', async () => {
const index = faker.number.int().toString();
const query = { bool: { must: [{ term: { status: 'active' } }] } };
const error = new Error('Count failed');
jest.spyOn(osClient, 'count').mockRejectedValue(error as never);
await expect(osRepo.getTotal(index, query)).rejects.toThrow(
'Count failed',
);
});
});
describe('deleteAllIndexes', () => {
it('deleting all indexes succeeds', async () => {
jest.spyOn(osClient.indices, 'delete').mockResolvedValue({
body: {
acknowledged: true,
},
} as never);
await osRepo.deleteAllIndexes();
expect(osClient.indices.delete).toHaveBeenCalledWith({
index: '_all',
});
});
it('deleting all indexes handles errors', async () => {
const error = new Error('Delete all failed');
jest.spyOn(osClient.indices, 'delete').mockRejectedValue(error as never);
await expect(osRepo.deleteAllIndexes()).rejects.toThrow(
'Delete all failed',
);
});
});
});
@@ -0,0 +1,263 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import {
Inject,
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Client, errors } from '@opensearch-project/opensearch';
import { Indices_PutMapping_Response } from '@opensearch-project/opensearch/api';
import type {
CreateDataDto,
CreateIndexDto,
DeleteBulkDataDto,
GetDataDto,
PutMappingsDto,
ScrollDto,
UpdateDataDto,
} from './dtos';
import { LargeWindowException } from './large-window.exception';
@Injectable()
export class OpensearchRepository {
private logger = new Logger(OpensearchRepository.name);
private opensearchClient: Client;
constructor(@Inject('OPENSEARCH_CLIENT') opensearchClient: Client) {
this.opensearchClient = opensearchClient;
}
async createIndex({ index }: CreateIndexDto) {
const indexName = 'channel_' + index;
try {
const response = await this.opensearchClient.indices.create({
index: indexName,
body: {
settings: {
index: { max_ngram_diff: 1 },
analysis: {
analyzer: {
ngram_analyzer: {
type: 'custom',
filter: ['lowercase', 'asciifolding', 'cjk_width'],
tokenizer: 'ngram_tokenizer',
},
},
tokenizer: {
ngram_tokenizer: {
type: 'ngram',
min_gram: 1,
max_gram: 2,
token_chars: ['letter', 'digit', 'punctuation', 'symbol'],
},
},
},
},
},
});
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (response) {
this.logger.log(
`Index created successfully: ${JSON.stringify(response.body, null, 2)}`,
);
}
} catch (error) {
this.logger.log(`Error creating index: ${error}`);
if (error?.meta?.body) {
this.logger.log(
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
);
}
throw error;
}
await this.opensearchClient.indices.putAlias({
index: indexName,
name: index,
});
}
async putMappings({ index, mappings }: PutMappingsDto) {
const { statusCode } = await this.opensearchClient.indices.exists({
index,
});
if (statusCode !== 200) throw new NotFoundException('index is not found');
let response: Indices_PutMapping_Response;
try {
response = await this.opensearchClient.indices.putMapping({
index,
body: { properties: mappings },
});
} catch (error) {
this.logger.log(`Error put mapping: ${error}`);
if (error?.meta?.body) {
this.logger.log(
`OpenSearch error details:${JSON.stringify(error.meta.body, null, 2)}`,
);
}
throw error;
}
return response;
}
async createData({ id, index, data }: CreateDataDto) {
const indexName = 'channel_' + index;
const existence = await this.opensearchClient.indices.exists({
index: indexName,
});
if (existence.statusCode !== 200)
throw new NotFoundException('index is not found');
const response = await this.opensearchClient.indices.getMapping({
index: indexName,
});
const mappingKeys = Object.keys(
response.body[indexName].mappings.properties as object,
);
const dataKeys = Object.keys(data);
if (!dataKeys.every((v) => mappingKeys.includes(v))) {
throw new InternalServerErrorException('error!!!');
}
const { body } = await this.opensearchClient.index({
id,
index: indexName,
body: data,
refresh: true,
});
return { id: body._id as unknown as number };
}
async getData(dto: GetDataDto) {
const { index, limit = 100, page = 1, query, sort } = dto;
if (sort.length === 0) {
sort.push('_id:desc');
}
try {
const { body } = await this.opensearchClient.search({
index,
from: (page - 1) * limit,
size: limit,
sort,
body: { query },
});
return {
items: body.hits.hits.map((v) => ({
...v._source,
})) as Record<string, any>[],
total:
typeof body.hits.total === 'number' ?
body.hits.total
: (body.hits.total?.value ?? 0),
};
} catch (error) {
if (error instanceof errors.OpenSearchClientError) {
if (error.message.includes('Result window is too large')) {
throw new LargeWindowException(error.message);
}
}
throw error;
}
}
async scroll(dto: ScrollDto) {
const { index, size, scrollId, query, sort } = dto;
if (sort.length === 0) sort.push('_id:desc');
if (scrollId) {
const { body } = await this.opensearchClient.scroll({
scroll_id: scrollId,
scroll: '1m',
});
return this.convertToScrollData(body);
}
const { body } = await this.opensearchClient.search({
index,
size,
sort,
body: { query },
scroll: '1m',
});
return this.convertToScrollData(body);
}
private convertToScrollData(body) {
return {
data: body.hits.hits.map((v) => ({
...v._source,
})) as Record<string, any>[],
scrollId: body._scroll_id,
};
}
async updateData({ id, index, data }: UpdateDataDto) {
try {
await this.opensearchClient.update({
id,
index,
body: { doc: data },
refresh: true,
retry_on_conflict: 5,
});
} catch (error) {
this.logger.error(`Error updating data: ${error}`);
if (error?.meta?.body) {
this.logger.error(
`OpenSearch error details: ${JSON.stringify(error.meta.body, null, 2)}`,
);
}
throw error;
}
}
async deleteBulkData({ ids, index }: DeleteBulkDataDto) {
await this.opensearchClient.deleteByQuery({
index,
body: { query: { terms: { _id: ids } } },
refresh: true,
});
}
async deleteIndex(index: string) {
await this.opensearchClient.indices.delete({ index: 'channel_' + index });
}
async deleteAllIndexes() {
await this.opensearchClient.indices.delete({ index: '_all' });
}
async getTotal(index: string, query: object): Promise<number> {
const { body } = await this.opensearchClient.count({
index,
body: { query },
});
return body.count;
}
}
@@ -0,0 +1,42 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
import type { ValidationArguments, ValidationOptions } from 'class-validator';
import { registerDecorator } from 'class-validator';
export const ArrayDistinct = (
property?: string,
validationOptions?: ValidationOptions,
) => {
return (object: object, propertyName: string) => {
registerDecorator({
name: 'ArrayDistinct',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: unknown): boolean {
return Array.isArray(value) ?
[...new Set(value)].length === value.length
: false;
},
defaultMessage(args: ValidationArguments): string {
return `must not contains duplicate entry for ${args.constraints[0]}`;
},
},
});
};
};
+16
View File
@@ -0,0 +1,16 @@
/**
* Copyright 2025 LY Corporation
*
* LY Corporation licenses this file to you under the Apache License,
* version 2.0 (the "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at:
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
export { ArrayDistinct } from './array-distinct';

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