diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..1f2ad60f --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# ------------------------ +# ✅ 공통 +# ------------------------ +.DS_Store +*.pem +.vscode +.github + +# 환경 변수 +.env* +*.env + +# 로그 파일 +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# 커버리지 +/coverage + +# 빌드 +/build +/dist + +# ------------------------ +# ✅ Node.js dependencies +# ------------------------ +/node_modules + +# ------------------------ +# ✅ yarn / pnpm / berry +# ------------------------ +.pnp.* +/.pnp +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# ------------------------ +# ✅ BE 관련 +prisma/dev.db +prisma/dev.db-journal +.env +node_modules +dist/* +!BE/dist/index.js +/src/generated/prisma diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..57bbc85b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:20.15.1-alpine3.19 + +ENV DOCKERIZE_VERSION v0.7.0 +RUN apk update --no-cache \ + && apk add --no-cache wget openssl \ + && wget -O - https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz | tar xzf - -C /usr/local/bin \ + && apk del wget + +WORKDIR /app + +EXPOSE 3000 + +COPY ./docker-entrypoint.sh ./docker-entrypoint.sh +RUN ["chmod", "+x", "./docker-entrypoint.sh"] +ENTRYPOINT ["sh", "./docker-entrypoint.sh"] diff --git a/constant/ExceptionMessage.ts b/constant/ExceptionMessage.ts new file mode 100644 index 00000000..7664d74c --- /dev/null +++ b/constant/ExceptionMessage.ts @@ -0,0 +1,15 @@ +/** + * [에러 메시지 상수] + * + * 에러 메시지가 반복적으로 사용되는 경우, 상수로 관리하는 것이 효율적입니다. + * + * 여러 장점들: + * - 오타 방지 + * - 추후 에러메시지 변경에 유리 + * - ... + */ +export const ExceptionMessage = { + ARTICLE_NOT_FOUND: 'Article not found', + PRODUCT_NOT_FOUND: 'Product not found', + COMMENT_NOT_FOUND: 'Comment not found', +}; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..bee056aa --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: '2.2' + +services: + pg-db: + image: postgres:16.3-alpine3.19 + attach: false + container_name: panda-market-db + restart: always + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=panda-market + networks: + - panda-market-network + ports: + - '15432:5432' + command: [postgres] + + service: + container_name: panda-market-app + build: + context: . + dockerfile: ./Dockerfile + restart: no + environment: + # Application + - NODE_ENV=development + - DATABASE_URL=postgresql://postgres:postgres@pg-db:5432/panda-market + - JWT_ACCESS_TOKEN_SECRET=$3cr3t + - JWT_REFRESH_TOKEN_SECRET=$3cr3t + networks: + - panda-market-network + ports: + - '13000:3000' + volumes: + - .:/app/ + depends_on: + - pg-db + +networks: + panda-market-network: + name: panda-market-network diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 00000000..05d8ca5b --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,7 @@ +dockerize -wait tcp://pg-db:5432 -timeout 60s + +npx prisma generate +npx prisma migrate deploy +npm run seed + +npm start \ No newline at end of file diff --git a/google-auth-setting.png b/google-auth-setting.png new file mode 100644 index 00000000..9d2a2e2b Binary files /dev/null and b/google-auth-setting.png differ diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 00000000..7fe83c48 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,730 @@ +openapi: 3.0.3 +info: + title: Swagger Petstore - OpenAPI 3.0 + version: 1.0.0 +servers: + - url: http://localhost:13000 +tags: + - name: Article + - name: Product + - name: Comment + - name: Image + +paths: + /articles: + post: + tags: + - Article + description: 게시글 생성 API + operationId: createArticle + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateArticleRequest' + responses: + '201': + description: 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/Article' + '400': + description: Validation failed + + get: + tags: + - Article + description: 게시글 목록 조회 API + operationId: getArticleList + parameters: + - name: cursor + in: query + required: false + schema: + type: integer + default: 0 + minimum: 0 + example: 0 + - name: take + in: query + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 10 + example: 10 + - name: orderBy + in: query + required: false + schema: + type: string + enum: + - recent + example: recent + - name: word + in: query + required: false + schema: + type: string + example: "검색어" + responses: + '200': + description: 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/GetArticleListResponse' + + /articles/{articleId}: + get: + tags: + - Article + description: 게시글 조회 API + operationId: getArticle + parameters: + - name: articleId + in: path + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: 성공 + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Article' + '404': + description: Not found + + patch: + tags: + - Article + description: "게시글 수정 API" + operationId: updateArticle + parameters: + - name: articleId + in: path + required: true + schema: + type: integer + example: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateArticleRequest' + responses: + '200': + description: 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/Article' + '400': + description: Validation failed + '404': + description: Not found + + delete: + tags: + - Article + description: "게시글 삭제 API" + operationId: deleteArticle + parameters: + - name: articleId + in: path + required: true + schema: + type: integer + example: 1 + responses: + '204': + description: 성공 + '404': + description: Not found + + /articles/{articleId}/comments: + post: + tags: + - Article + description: 댓글 생성 API + operationId: createArticleComment + parameters: + - name: articleId + in: path + required: true + schema: + type: integer + example: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateArticleCommentRequest' + + responses: + '201': + description: 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + description: Validation failed + '404': + description: Not found + + get: + tags: + - Article + description: 댓글 목록 조회 API + operationId: getArticleCommentList + parameters: + - name: articleId + in: path + required: true + schema: + type: integer + example: 1 + - name: cursor + in: query + required: false + schema: + type: integer + default: 0 + minimum: 0 + example: 0 + - name: take + in: query + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 10 + example: 10 + responses: + '200': + description: 성공 + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/GetArticleCommentListResponse' + '404': + description: Not found' + + /products: + post: + tags: + - Product + description: 상품 생성 API + operationId: createProduct + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProductRequest' + responses: + '201': + description: 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + '400': + description: Validation failed + + get: + tags: + - Product + description: 상품 목록 조회 API + operationId: getProductList + parameters: + - name: skip + in: query + required: false + schema: + type: integer + default: 0 + minimum: 0 + example: 0 + - name: take + in: query + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 10 + example: 10 + - name: orderBy + in: query + required: false + schema: + type: string + enum: + - recent + example: recent + - name: word + in: query + required: false + schema: + type: string + example: "검색어" + responses: + '200': + description: 성공 + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Product' + + /products/{productId}: + get: + tags: + - Product + description: 상품 조회 API + operationId: getProduct + parameters: + - name: productId + in: path + required: true + schema: + type: integer + example: 1 + responses: + '200': + description: 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + '404': + description: Not found + + patch: + tags: + - Product + description: "상품 수정 API" + operationId: updateProduct + parameters: + - name: productId + in: path + required: true + schema: + type: integer + example: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProductRequest' + responses: + '200': + description: 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + '400': + description: Validation failed + '404': + description: Not found + + delete: + tags: + - Product + description: "상품 삭제 API" + operationId: deleteProduct + parameters: + - name: productId + in: path + required: true + schema: + type: integer + example: 1 + responses: + '204': + description: 성공 + '404': + description: Not found + + /products/{productId}/comments: + post: + tags: + - Product + description: 댓글 생성 API + operationId: createProductComment + parameters: + - name: productId + in: path + required: true + schema: + type: integer + example: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProductCommentRequest' + responses: + '201': + description: 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + description: Validation failed + '404': + description: Not found + + get: + tags: + - Product + description: 댓글 목록 조회 API + operationId: getProductCommentList + parameters: + - name: productId + in: path + required: true + schema: + type: integer + example: 1 + - name: cursor + in: query + required: false + schema: + type: integer + default: 0 + minimum: 0 + example: 0 + - name: take + in: query + required: false + schema: + type: integer + default: 10 + minimum: 1 + maximum: 10 + example: 10 + responses: + '200': + description: 성공 + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/GetProductCommentListResponse' + '404': + description: Not found + + /comments/{commentId}: + patch: + tags: + - Comment + description: "댓글 수정 API" + operationId: updateComment + parameters: + - name: commentId + in: path + required: true + schema: + type: integer + example: 1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCommentRequest' + responses: + '200': + description: 성공 + content: + application/json: + schema: + $ref: '#/components/schemas/Comment' + '400': + description: Validation failed + '404': + description: Not found + + delete: + tags: + - Comment + description: "댓글 삭제 API" + operationId: deleteComment + parameters: + - name: commentId + in: path + required: true + schema: + type: integer + example: 1 + responses: + '204': + description: 성공 + '404': + description: Not found + + /images/upload: + post: + tags: + - Image + description: 이미지 업로드 API + operationId: uploadImage + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + image: + type: string + format: binary + responses: + '201': + description: 성공 + content: + application/json: + schema: + type: object + properties: + url: + type: string + format: url + example: "http://localhost:13000/static/images/sample-image.png" + '400': + description: Validation failed + +components: + schemas: + CreateArticleRequest: + type: object + required: + - title + - content + - image + properties: + title: + type: string + example: "게시글 제목" + content: + type: string + example: "게시글 내용" + image: + type: string + format: url + nullable: true + example: "http://localhost:13000/static/images/sample-image.png" + + GetArticleListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Article' + nextCursor: + type: integer + example: 10 + hasMore: + type: boolean + example: true + + UpdateArticleRequest: + type: object + properties: + title: + type: string + example: "게시글 제목" + content: + type: string + example: "게시글 내용" + image: + type: string + format: url + nullable: true + example: "http://localhost:13000/static/images/sample-image.png" + + CreateArticleCommentRequest: + type: object + required: + - content + properties: + content: + type: string + example: "댓글 내용" + + GetArticleCommentListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Comment' + nextCursor: + type: integer + example: 10 + hasMore: + type: boolean + example: true + + CreateProductRequest: + type: object + required: + - name + - description + - price + - tags + - images + properties: + name: + type: string + example: "상품 이름" + description: + type: string + example: "상품 설명" + price: + type: integer + minimum: 0 + example: 10000 + tags: + type: array + items: + type: string + example: ["태그1", "태그2"] + images: + type: array + items: + type: string + example: ["http://localhost:13000/static/images/sample-image.png"] + + UpdateProductRequest: + type: object + properties: + name: + type: string + example: "상품 이름" + description: + type: string + example: "상품 설명" + price: + type: integer + minimum: 0 + example: 10000 + tags: + type: array + items: + type: string + example: ["태그1", "태그2"] + images: + type: array + items: + type: string + example: ["http://localhost:13000/static/images/sample-image.png"] + + CreateProductCommentRequest: + type: object + required: + - content + properties: + content: + type: string + example: "댓글 내용" + + GetProductCommentListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Comment' + nextCursor: + type: integer + example: 10 + hasMore: + type: boolean + example: true + + UpdateCommentRequest: + type: object + properties: + content: + type: string + example: "댓글 내용" + + Article: + type: object + properties: + id: + type: integer + example: 1 + title: + type: string + example: "게시글 제목" + content: + type: string + example: "게시글 내용" + image: + type: string + format: url + nullable: true + example: "http://localhost:13000/static/images/sample-image.png" + createdAt: + type: string + format: datetime + example: "2024-07-15T00:00:00.000Z" + + Product: + type: object + properties: + id: + type: integer + example: 1 + name: + type: string + example: "상품 이름" + description: + type: string + example: "상품 설명" + price: + type: integer + example: 10000 + tags: + type: array + items: + type: string + example: ["태그1", "태그2"] + images: + type: array + items: + type: string + example: ["http://localhost:13000/static/images/sample-image.png"] + createdAt: + type: string + format: datetime + example: "2024-07-15T00:00:00.000Z" + + Comment: + type: object + properties: + id: + type: integer + content: + type: string + createdAt: + type: string + format: datetime + example: "2024-07-15T00:00:00.000Z" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..f9edc3a5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2538 @@ +{ + "name": "ts-sprint11-be", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ts-sprint11-be", + "version": "1.0.0", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "@prisma/client": "^6.9.0", + "@types/cors": "^2.8.19", + "@types/is-email": "^1.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", + "@types/multer": "^1.4.13", + "@types/swagger-ui-express": "^4.1.8", + "axios": "^1.9.0", + "cors": "^2.8.5", + "dotenv": "^16.5.0", + "express": "^5.1.0", + "is-email": "^1.0.2", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "multer": "^2.0.1", + "prettier": "^3.5.3", + "prisma": "^6.9.0", + "superstruct": "^2.0.2", + "swagger-ui-express": "^5.0.1", + "yaml": "^2.8.0" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "nodemon": "^3.1.10", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.8.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@prisma/client": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.9.0.tgz", + "integrity": "sha512-Gg7j1hwy3SgF1KHrh0PZsYvAaykeR0PaxusnLXydehS96voYCGt1U5zVR31NIouYc63hWzidcrir1a7AIyCsNQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.9.0.tgz", + "integrity": "sha512-Wcfk8/lN3WRJd5w4jmNQkUwhUw0eksaU/+BlAJwPQKW10k0h0LC9PD/6TQFmqKVbHQL0vG2z266r0S1MPzzhbA==", + "license": "Apache-2.0", + "dependencies": { + "jiti": "2.4.2" + } + }, + "node_modules/@prisma/debug": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.9.0.tgz", + "integrity": "sha512-bFeur/qi/Q+Mqk4JdQ3R38upSYPebv5aOyD1RKywVD+rAMLtRkmTFn28ZuTtVOnZHEdtxnNOCH+bPIeSGz1+Fg==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.9.0.tgz", + "integrity": "sha512-im0X0bwDLA0244CDf8fuvnLuCQcBBdAGgr+ByvGfQY9wWl6EA+kRGwVk8ZIpG65rnlOwtaWIr/ZcEU5pNVvq9g==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.9.0", + "@prisma/engines-version": "6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e", + "@prisma/fetch-engine": "6.9.0", + "@prisma/get-platform": "6.9.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e.tgz", + "integrity": "sha512-Qp9gMoBHgqhKlrvumZWujmuD7q4DV/gooEyPCLtbkc13EZdSz2RsGUJ5mHb3RJgAbk+dm6XenqG7obJEhXcJ6Q==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.9.0.tgz", + "integrity": "sha512-PMKhJdl4fOdeE3J3NkcWZ+tf3W6rx3ht/rLU8w4SXFRcLhd5+3VcqY4Kslpdm8osca4ej3gTfB3+cSk5pGxgFg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.9.0", + "@prisma/engines-version": "6.9.0-10.81e4af48011447c3cc503a190e86995b66d2a28e", + "@prisma/get-platform": "6.9.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.9.0.tgz", + "integrity": "sha512-/B4n+5V1LI/1JQcHp+sUpyRT1bBgZVPHbsC4lt4/19Xp4jvNIVcq5KYNtQDk5e/ukTSjo9PZVAxxy9ieFtlpTQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.9.0" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", + "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", + "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/is-email": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/is-email/-/is-email-1.0.0.tgz", + "integrity": "sha512-b/76ooKpYY/b+oPrOuc/pmM5eag+ZlzctPsKcRCIKs+TFzh0FL58OeXtSPkbXt3uKNK84YCKHmjnoREtwve5Kg==", + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/morgan": { + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", + "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "24.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.1.tgz", + "integrity": "sha512-MX4Zioh39chHlDJbKmEgydJDS3tspMP/lnQC67G3SWsTnb9NeYVWOjkxpOSy4oMfPs4StcWHwBrvUb4ybfnuaw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/basic-auth": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", + "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.1.2" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/basic-auth/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", + "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-email": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-email/-/is-email-1.0.2.tgz", + "integrity": "sha512-UojUgD2EhDTBQ2SGKwrK9edce5phRzgLsP+V5+Uu2Swi+uvjVXgH3zduM3HhT9iaC/9Kq19/TYUbP0jPoi6ioA==", + "license": "SEE LICENSE IN LICENSE" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", + "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/morgan": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", + "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "license": "MIT", + "dependencies": { + "basic-auth": "~2.0.1", + "debug": "2.6.9", + "depd": "~2.0.0", + "on-finished": "~2.3.0", + "on-headers": "~1.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/morgan/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/morgan/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/morgan/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", + "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prettier": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", + "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.9.0.tgz", + "integrity": "sha512-resJAwMyZREC/I40LF6FZ6rZTnlrlrYrb63oW37Gq+U+9xHwbyMSPJjKtM7VZf3gTO86t/Oyz+YeSXr3CmAY1Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.9.0", + "@prisma/engines": "6.9.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.6.3", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superstruct": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", + "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.24.1", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.24.1.tgz", + "integrity": "sha512-ITeWc7CCAfK53u8jnV39UNqStQZjSt+bVYtJHsOEL3vVj/WV9/8HmsF8Ej4oD8r+Xk1HpWyeW/t59r1QNeAcUQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/ts-node-dev/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz", + "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..96ad3be8 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "ts-sprint11-be", + "version": "1.0.0", + "main": "app.js", + "scripts": { + "dev": "ts-node-dev --respawn src/main.ts", + "build": "tsc", + "start": "node --enable-source-maps dist/main.js", + "migrate": "prisma migrate dev", + "studio": "prisma studio", + "postinstall": "prisma generate" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "@prisma/client": "^6.9.0", + "@types/cors": "^2.8.19", + "@types/is-email": "^1.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", + "@types/multer": "^1.4.13", + "@types/swagger-ui-express": "^4.1.8", + "axios": "^1.9.0", + "cors": "^2.8.5", + "dotenv": "^16.5.0", + "express": "^5.1.0", + "is-email": "^1.0.2", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "multer": "^2.0.1", + "prettier": "^3.5.3", + "prisma": "^6.9.0", + "superstruct": "^2.0.2", + "swagger-ui-express": "^5.0.1", + "yaml": "^2.8.0" + }, + "devDependencies": { + "@types/express": "^5.0.3", + "nodemon": "^3.1.10", + "ts-node": "^10.9.2", + "ts-node-dev": "^2.0.0", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.8.3" + }, + "_moduleAliases": { + "@": "dist" + } +} diff --git a/prisma/migrations/20250613074707_new_table/migration.sql b/prisma/migrations/20250613074707_new_table/migration.sql new file mode 100644 index 00000000..ed8395ae --- /dev/null +++ b/prisma/migrations/20250613074707_new_table/migration.sql @@ -0,0 +1,107 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "nickname" TEXT NOT NULL, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Article" ( + "id" SERIAL NOT NULL, + "writerId" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "image" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Article_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Product" ( + "id" SERIAL NOT NULL, + "ownerId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL, + "price" INTEGER NOT NULL, + "tags" TEXT[], + "images" TEXT[], + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Comment" ( + "id" SERIAL NOT NULL, + "writerId" INTEGER NOT NULL, + "content" TEXT NOT NULL, + "productId" INTEGER, + "articleId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Comment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Like" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "productId" INTEGER, + "articleId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Like_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "token" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Like_userId_productId_key" ON "Like"("userId", "productId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Like_userId_articleId_key" ON "Like"("userId", "articleId"); + +-- AddForeignKey +ALTER TABLE "Article" ADD CONSTRAINT "Article_writerId_fkey" FOREIGN KEY ("writerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Product" ADD CONSTRAINT "Product_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_writerId_fkey" FOREIGN KEY ("writerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Comment" ADD CONSTRAINT "Comment_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Like" ADD CONSTRAINT "Like_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Like" ADD CONSTRAINT "Like_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Like" ADD CONSTRAINT "Like_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 00000000..044d57cd --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/mocks/articleMocks.js b/prisma/mocks/articleMocks.js new file mode 100644 index 00000000..9c8ec09f --- /dev/null +++ b/prisma/mocks/articleMocks.js @@ -0,0 +1,32 @@ +export const ArticleMocks = [ + { + writerId: 1, + title: 'Article 1', + content: 'Article 1 content', + image: null, + }, + { + writerId: 1, + title: 'Article 2', + content: 'Article 2 content', + image: null, + }, + { + writerId: 1, + title: 'Article 3', + content: 'Article 3 content', + image: null, + }, + { + writerId: 1, + title: 'Article 4', + content: 'Article 4 content', + image: null, + }, + { + writerId: 1, + title: 'Article 5', + content: 'Article 5 content', + image: null, + }, +]; diff --git a/prisma/mocks/comments.js b/prisma/mocks/comments.js new file mode 100644 index 00000000..600d6f68 --- /dev/null +++ b/prisma/mocks/comments.js @@ -0,0 +1,62 @@ +export const CommentMocks = [ + { + writerId: 1, + content: 'Article 1 Comment 1', + articleId: 1, + }, + { + writerId: 1, + content: 'Article 1 Comment 2', + articleId: 1, + }, + { + writerId: 1, + content: 'Article 2 Comment 1', + articleId: 2, + }, + { + writerId: 1, + content: 'Article 3 Comment 1', + articleId: 3, + }, + { + writerId: 1, + content: 'Article 4 Comment 1', + articleId: 4, + }, + { + writerId: 1, + content: 'Article 5 Comment 1', + articleId: 5, + }, + { + writerId: 1, + content: 'Product 1 Comment 1', + productId: 1, + }, + { + writerId: 1, + content: 'Product 1 Comment 2', + productId: 1, + }, + { + writerId: 1, + content: 'Product 2 Comment 1', + productId: 2, + }, + { + writerId: 1, + content: 'Product 3 Comment 1', + productId: 3, + }, + { + writerId: 1, + content: 'Product 4 Comment 1', + productId: 4, + }, + { + writerId: 1, + content: 'Product 5 Comment 1', + productId: 5, + }, +]; diff --git a/prisma/mocks/likeMocks.js b/prisma/mocks/likeMocks.js new file mode 100644 index 00000000..6685ac7c --- /dev/null +++ b/prisma/mocks/likeMocks.js @@ -0,0 +1,14 @@ +export const LikeMocks = [ + { + userId: 1, + productId: 1, + }, + { + userId: 1, + productId: 2, + }, + { + userId: 1, + articleId: 1, + }, +]; diff --git a/prisma/mocks/productMocks.js b/prisma/mocks/productMocks.js new file mode 100644 index 00000000..4124ede5 --- /dev/null +++ b/prisma/mocks/productMocks.js @@ -0,0 +1,42 @@ +export const ProductMocks = [ + { + ownerId: 1, + name: 'Product 1', + description: 'Product 1 description', + price: 10, + tags: ['tag1'], + images: [], + }, + { + ownerId: 1, + name: 'Product 2', + description: 'Product 2 description', + price: 20, + tags: ['tag1', 'tag2'], + images: [], + }, + { + ownerId: 1, + name: 'Product 3', + description: 'Product 3 description', + price: 30, + tags: ['tag3'], + images: [], + }, + { + ownerId: 1, + name: 'Product 4', + description: 'Product 4 description', + price: 40, + tags: [], + images: [], + }, + { + ownerId: 1, + name: 'Product 5', + description: 'Product 5 description', + price: 50, + tags: [], + images: [], + }, +]; diff --git a/prisma/mocks/userMocks.js b/prisma/mocks/userMocks.js new file mode 100644 index 00000000..e249685c --- /dev/null +++ b/prisma/mocks/userMocks.js @@ -0,0 +1,10 @@ +import { UserPasswordBuilder } from '../../src/infra/UserPasswordBuilder.js'; + +export const UserMocks = [ + { + email: 'firstUser@pandamarket.com', + password: UserPasswordBuilder.hashPassword('password'), + nickname: 'firstUser', + image: null, + }, +]; diff --git a/prisma/prismaClient.ts b/prisma/prismaClient.ts new file mode 100644 index 00000000..0a7fbaa8 --- /dev/null +++ b/prisma/prismaClient.ts @@ -0,0 +1,2 @@ +import { PrismaClient } from '@prisma/client'; +export const prisma = new PrismaClient(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..098268e3 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,90 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + password String + nickname String + image String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + Article Article[] + Product Product[] + Comment Comment[] + Like Like[] +} + +model Article { + id Int @id @default(autoincrement()) + writer User @relation(fields: [writerId], references: [id], onDelete: Cascade) + writerId Int + title String + content String + image String? + ArticleComment Comment[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + likes Like[] +} + +model Product { + id Int @id @default(autoincrement()) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + ownerId Int + name String + description String + price Int + tags String[] + images String[] + ProductComment Comment[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + likes Like[] +} + +model Comment { + id Int @id @default(autoincrement()) + writer User @relation(fields: [writerId], references: [id], onDelete: Cascade) + writerId Int + content String + product Product? @relation(fields: [productId], references: [id], onDelete: Cascade) + productId Int? + article Article? @relation(fields: [articleId], references: [id], onDelete: Cascade) + articleId Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model Like { + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId Int + product Product? @relation(fields: [productId], references: [id], onDelete: Cascade) + productId Int? + article Article? @relation(fields: [articleId], references: [id], onDelete: Cascade) + articleId Int? + createdAt DateTime @default(now()) + + @@unique([userId, productId]) + @@unique([userId, articleId]) +} + +model RefreshToken { + id Int @id @default(autoincrement()) + userId Int + token String + createdAt DateTime @default(now()) +} diff --git a/prisma/seed.js b/prisma/seed.js new file mode 100644 index 00000000..fad204f5 --- /dev/null +++ b/prisma/seed.js @@ -0,0 +1,37 @@ +import { PrismaClient } from '@prisma/client'; +import { ArticleMocks } from './mocks/articleMocks.js'; +import { ProductMocks } from './mocks/productMocks.js'; +import { CommentMocks } from './mocks/comments.js'; + +const prisma = new PrismaClient(); + +async function main() { + // 기존 데이터 삭제 + await prisma.article.deleteMany(); + await prisma.product.deleteMany(); + await prisma.comment.deleteMany(); + + // 목 데이터 삽입 + await prisma.article.createMany({ + data: ArticleMocks, + skipDuplicates: true, + }); + await prisma.product.createMany({ + data: ProductMocks, + skipDuplicates: true, + }); + await prisma.comment.createMany({ + data: CommentMocks, + skipDuplicates: true, + }); +} + +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/src/application/article/CreateArticleCommentHandler.ts b/src/application/article/CreateArticleCommentHandler.ts new file mode 100644 index 00000000..eae425e1 --- /dev/null +++ b/src/application/article/CreateArticleCommentHandler.ts @@ -0,0 +1,68 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Comment } from '../../domain/Comment'; +import { User } from '../../domain/User'; +import { TArticle, TArticleUser } from '@/types/article'; + +type TCreateArticleComment = { + articleId : number; + content: string; +} + +export class CreateArticleCommentHandler { + static async handle(requester: TArticleUser, { articleId, content }: TCreateArticleComment) { + /** + * [게시글 댓글 등록 트랜잭션] + * + * 1. 게시글이 존재하는지 확인합니다. + * 2. 게시글이 존재한다면, 댓글을 등록합니다. + */ + const commentEntity = await prismaClient.$transaction(async (tx) => { + const targetArticleEntity = await tx.article.findUnique({ + where: { + id: articleId, + }, + }); + + if (!targetArticleEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.ARTICLE_NOT_FOUND); + } + + return await tx.comment.create({ + data: { + articleId, + writerId: requester.userId, + content, + }, + }); + }); + + const comment = new Comment(commentEntity); + + const writerEntity = await prismaClient.user.findUnique({ + where: { + id: comment.getWriterId(), + }, + }); + + if(!writerEntity) { + throw new Error('User Not Found') + } + + const writer = new User(writerEntity); + + return { + id: comment.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + image: writer.getImage(), + }, + content: comment.getContent(), + createdAt: comment.getCreatedAt(), + }; + } +} diff --git a/src/application/article/CreateArticleHandler.ts b/src/application/article/CreateArticleHandler.ts new file mode 100644 index 00000000..5e867c63 --- /dev/null +++ b/src/application/article/CreateArticleHandler.ts @@ -0,0 +1,58 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { Article } from '../../domain/Article'; +import { User } from '../../domain/User'; +import { TArticle, TArticleUser } from '@/types/article'; + +type TCreateArticle = { + title: string; + content: string; + image: string | null; +} + +export class CreateArticleHandler { + static async handle(requester: TArticleUser, { title, content, image } : TCreateArticle) { + const articleEntity = await prismaClient.article.create({ + data: { + writerId: requester.userId, + title, + content, + image, + }, + }); + + /** + * [클래스 객체로 변환] + * + * articleEntity 는 Article 클래스의 인스턴스가 아니므로, + * Article 클래스에 정의된 메서드를 사용할 수 없습니다. + */ + const article = new Article(articleEntity); + + const writerEntity = await prismaClient.user.findUnique({ + where: { + id: article.getWriterId(), + }, + }); + + if(!writerEntity) { + throw new Error('User Not Found') + } + + const writer = new User(writerEntity); + + return { + id: article.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + image: writer.getImage(), + }, + title: article.getTitle(), + content: article.getContent(), + image: article.getImage(), + createdAt: article.getCreatedAt(), + updatedAt: article.getUpdatedAt(), + }; + } +} diff --git a/src/application/article/CreateArticleLikeHandler.ts b/src/application/article/CreateArticleLikeHandler.ts new file mode 100644 index 00000000..d48c38e9 --- /dev/null +++ b/src/application/article/CreateArticleLikeHandler.ts @@ -0,0 +1,75 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Article } from '../../domain/Article'; +import { User } from '../../domain/User'; +import { TArticle, TArticleUser } from '@/types/article'; + +type TCreateArticleLike = { + articleId: number; +} + + +export class CreateArticleLikeHandler { + static async handle(requester: TArticleUser, { articleId } : TCreateArticleLike) { + const articleEntity = await prismaClient.$transaction(async (tx) => { + const targetArticleEntity = await tx.article.findUnique({ + where: { + id: articleId, + }, + }); + + if (!targetArticleEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.ARTICLE_NOT_FOUND); + } + + const likeEntity = await tx.like.findUnique({ + where: { + userId_articleId: { + userId: requester.userId, + articleId, + }, + }, + }); + + if (!likeEntity) { + await tx.like.create({ + data: { + userId: requester.userId, + articleId, + }, + }); + } + + return targetArticleEntity; + }); + const article = new Article(articleEntity); + + const writerEntity = await prismaClient.user.findUnique({ + where: { + id: articleEntity.writerId, + }, + }); + + if(!writerEntity) { + throw new Error('User Not Found') + } + + const writer = new User(writerEntity); + + return { + id: article.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + }, + title: article.getTitle(), + content: article.getContent(), + image: article.getImage(), + createdAt: article.getCreatedAt(), + isFavorite: true, + }; + } +} diff --git a/src/application/article/DeleteArticleHandler.ts b/src/application/article/DeleteArticleHandler.ts new file mode 100644 index 00000000..237829bb --- /dev/null +++ b/src/application/article/DeleteArticleHandler.ts @@ -0,0 +1,36 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ForbiddenException } from '../../exceptions/ForbiddenException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; +import { TArticle, TArticleUser } from '@/types/article'; + +type TDeleteArticle = { + articleId: number; +} + +export class DeleteArticleHandler { + static async handle(requester: TArticleUser, { articleId } : TDeleteArticle) { + await prismaClient.$transaction(async (tx) => { + const targetArticleEntity = await tx.article.findUnique({ + where: { + id: articleId, + }, + }); + + if (!targetArticleEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.ARTICLE_NOT_FOUND); + } + + if (targetArticleEntity.writerId !== requester.userId) { + throw new ForbiddenException('Forbidden', ExceptionMessage.FORBIDDEN); + } + + return await tx.article.delete({ + where: { + id: articleId, + }, + }); + }); + } +} diff --git a/src/application/article/DeleteArticleLikeHandler.ts b/src/application/article/DeleteArticleLikeHandler.ts new file mode 100644 index 00000000..980bc464 --- /dev/null +++ b/src/application/article/DeleteArticleLikeHandler.ts @@ -0,0 +1,77 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { Article } from '../../domain/Article'; +import { User } from '../../domain/User'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; +import { TArticle, TArticleUser } from '@/types/article'; + +type DeleteArticleLike = { + articleId: number; +} + +export class DeleteArticleLikeHandler { + static async handle(requester: TArticleUser, { articleId } : DeleteArticleLike) { + const articleEntity = await prismaClient.$transaction(async (tx) => { + const targetArticleEntity = await tx.article.findUnique({ + where: { + id: articleId, + }, + }); + + if (!targetArticleEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.ARTICLE_NOT_FOUND); + } + + const likeEntity = await tx.like.findUnique({ + where: { + userId_articleId: { + userId: requester.userId, + articleId, + }, + }, + }); + + if (likeEntity) { + await tx.like.delete({ + where: { + userId_articleId: { + userId: requester.userId, + articleId, + }, + }, + }); + } + + return targetArticleEntity; + }); + + const article = new Article(articleEntity); + + const writerEntity = await prismaClient.user.findUnique({ + where: { + id: articleEntity.writerId, + }, + }); + + if(!writerEntity) { + throw new Error('User Not Found') + } + + const writer = new User(writerEntity); + + return { + id: article.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + }, + title: article.getTitle(), + content: article.getContent(), + image: article.getImage(), + createdAt: article.getCreatedAt(), + isFavorite: false, + }; + } +} diff --git a/src/application/article/GetArticleCommentListHandler.ts b/src/application/article/GetArticleCommentListHandler.ts new file mode 100644 index 00000000..47231633 --- /dev/null +++ b/src/application/article/GetArticleCommentListHandler.ts @@ -0,0 +1,79 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Comment } from '../../domain/Comment'; +import { User } from '../../domain/User'; + +type TGetArticleCommentList = { + articleId: number; + cursor?: number | undefined; + take?: number | undefined; +} + +export class GetArticleCommentListHandler { + static async handle({ articleId, cursor, take } : TGetArticleCommentList) { + const commentEntities = await prismaClient.$transaction(async (tx) => { + const targetArticleEntity = await tx.article.findUnique({ + where: { + id: articleId, + }, + }); + + if (!targetArticleEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.ARTICLE_NOT_FOUND); + } + + return await tx.comment.findMany({ + cursor: cursor + ? { + id: cursor, + } + : undefined, + take: Number(take) + 1, + where: { + articleId: articleId, + }, + }); + }); + + + const comments = commentEntities.map((commentEntity) => new Comment(commentEntity)); + + const writerEntities = await prismaClient.user.findMany({ + where: { + id: { + in: Array.from(new Set(comments.map((comment) => comment.getWriterId()))), + }, + }, + }); + + const writers = writerEntities.map((writerEntity) => new User(writerEntity)); + + const hasNext = comments.length === Number(take) + 1; + + return { + data: comments.slice(0, take).map((comment) => { + const writer = writers.find((writer) => writer.getId() === comment.getWriterId()); + + if (!writer) { + throw new Error('Writer not found'); + } + return { + id: comment.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + image: writer.getImage(), + }, + articleId: comment.getArticleId(), + content: comment.getContent(), + createdAt: comment.getCreatedAt(), + }; + }), + hasNext, + nextCursor: hasNext ? comments[comments.length - 1].getId() : null, + }; + } +} diff --git a/src/application/article/GetArticleHandler.ts b/src/application/article/GetArticleHandler.ts new file mode 100644 index 00000000..dd8dabca --- /dev/null +++ b/src/application/article/GetArticleHandler.ts @@ -0,0 +1,62 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Article } from '../../domain/Article'; +import { User } from '../../domain/User'; +import { TArticle, TArticleUser } from '@/types/article'; + +type TGetArticle = { + articleId: number; +} + +export class GetArticleHandler { + static async handle(requester: TArticleUser, { articleId } : TGetArticle) { + const articleEntity = await prismaClient.article.findUnique({ + where: { + id: Number(articleId), // params 에서 가져온 값은 문자열이므로, 여기서는 숫자로 변환하여 사용해야 합니다. + }, + include: { + likes: { + select: { + // 좋아요의 id, userId만 필요함 + id: true, + userId: true, + }, + }, + }, + }); + + if (!articleEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.ARTICLE_NOT_FOUND); + } + + const article = new Article(articleEntity); + + const writerEntity = await prismaClient.user.findUnique({ + where: { + id: article.getWriterId(), + }, + }); + + if(!writerEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.ARTICLE_NOT_FOUND) + } + const writer = new User(writerEntity); + + return { + id: article.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + }, + title: article.getTitle(), + content: article.getContent(), + image: article.getImage(), + createdAt: article.getCreatedAt(), + favoriteCount: article.getFavoriteCount(), + isFavorite: article.getIsFavorite(requester.userId), + }; + } +} diff --git a/src/application/article/GetArticleListHandler.ts b/src/application/article/GetArticleListHandler.ts new file mode 100644 index 00000000..a1bd20ec --- /dev/null +++ b/src/application/article/GetArticleListHandler.ts @@ -0,0 +1,100 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { Article } from '../../domain/Article'; +import { User } from '../../domain/User'; +import { Like } from '../../domain/Like'; +import { TArticle, TArticleUser } from '@/types/article'; +import { Prisma } from '@prisma/client'; + +type TetArticleList = { + cursor?: number | undefined; + limit?: number | undefined; + orderBy? : 'favorite' | 'recent'; + keyword? : string | undefined; +} + +export class GetArticleListHandler { + static async handle(requester: TArticleUser, { cursor, limit, orderBy, keyword } : TetArticleList) { + const orderByOption = + orderBy === 'favorite' + ? { _count: { likes: 'desc' as Prisma.SortOrder } } + : { createdAt: 'desc' as Prisma.SortOrder }; + + const articleEntities = await prismaClient.article.findMany({ + cursor: cursor + ? { + id: cursor, + } + : undefined, + take: Number(limit) + 1, + orderBy: orderByOption, + where: { + title: keyword ? { contains: keyword } : undefined, + }, + }); + + const articles = articleEntities.map( + (articleEntity) => new Article(articleEntity) + ); + + const writerEntities = await prismaClient.user.findMany({ + where: { + id: { + in: Array.from( + new Set(articles.map((article) => article.getWriterId())) + ), + }, + }, + }); + + const writers = writerEntities.map( + (writerEntity) => new User(writerEntity) + ); + + const likeEntities = await prismaClient.like.findMany({ + where: { + userId: requester.userId, + articleId: { + in: Array.from(new Set(articles.map((article) => article.getId()))), + }, + }, + }); + + const likes = likeEntities.map((likeEntity) => new Like({ + id: likeEntity.id, + userId: likeEntity.userId, + productId: likeEntity.productId ?? 0, + articleId: likeEntity.articleId ?? 0, + createdAt: likeEntity.createdAt ?? new Date(), + })); + + const hasNext = articles.length === Number(limit) + 1; + + return { + list: articles.slice(0, limit).map((article) => { + const writer = writers.find( + (writer) => writer.getId() === article.getWriterId() + ); + const like = likes.find( + (like) => like.getArticleId() === article.getId() + ); + if(!writer) { + throw new Error('작성자를 찾을 수 없습니다.') + } + return { + id: article.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + }, + title: article.getTitle(), + content: article.getContent(), + image: article.getImage(), + createdAt: article.getCreatedAt(), + isFavorite: !!like, + }; + }), + nextCursor: hasNext ? articles[articles.length - 1].getId() : null, + }; + } +} diff --git a/src/application/article/UpdateArticleHandler.ts b/src/application/article/UpdateArticleHandler.ts new file mode 100644 index 00000000..00ea85ba --- /dev/null +++ b/src/application/article/UpdateArticleHandler.ts @@ -0,0 +1,80 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ForbiddenException } from '../../exceptions/ForbiddenException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Article } from '../../domain/Article'; +import { User } from '../../domain/User'; +import { TArticleUser } from '@/types/article'; + +type TUpdateArticle = { + articleId: number; + title: string | undefined; + content: string | undefined; + image: string | undefined | null; +} + +export class UpdateArticleHandler { + static async handle(requester: TArticleUser, { articleId, title, content, image } : TUpdateArticle) { + /** + * [게시글 수정 트랜잭션] + * + * 1. 게시글을 수정하기 전에 해당 게시글이 존재하는지 확인합니다. + * 2. 게시글이 존재한다면, 게시글을 수정합니다. + * + * update() 하나만 사용해도 결과적으로는 동일합니다. + */ + const articleEntity = await prismaClient.$transaction(async (tx) => { + const targetArticleEntity = await tx.article.findUnique({ + where: { + id: articleId, + }, + }); + + if (!targetArticleEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.ARTICLE_NOT_FOUND); + } + + if (targetArticleEntity.writerId !== requester.userId) { + throw new ForbiddenException('Forbidden', ExceptionMessage.FORBIDDEN); + } + + return await tx.article.update({ + where: { + id: articleId, + }, + data: { + title, + content, + image, + }, + }); + }); + + const article = new Article(articleEntity); + + const writerEntity = await prismaClient.user.findUnique({ + where: { + id: article.getWriterId(), + }, + }); + + if(!writerEntity) { + throw new Error('User Not Found') + } + const writer = new User(writerEntity); + + return { + id: article.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + }, + title: article.getTitle(), + content: article.getContent(), + image: article.getImage(), + createdAt: article.getCreatedAt(), + }; + } +} diff --git a/src/application/auth/AuthByGoogleHandler.ts b/src/application/auth/AuthByGoogleHandler.ts new file mode 100644 index 00000000..b387b533 --- /dev/null +++ b/src/application/auth/AuthByGoogleHandler.ts @@ -0,0 +1,88 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { AuthTokenManager } from '../../infra/AuthTokenManager'; +import { googleOAuthHelper } from '../../infra/GoogleOAuthAdapter'; + +import { InternalServerErrorException } from '../../exceptions/InternalServerErrorException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { User } from '../../domain/User'; + +export class AuthByGoogleHandler { + static async handle({ code } : { code: string }) { + const googleAccessToken = await googleOAuthHelper.getAccessToken(code); + const googleProfile = await googleOAuthHelper.getProfile(googleAccessToken); + + // 이미 존재하는 이메일인지 확인 + const existingUserEntity = await prismaClient.user.findUnique({ + where: { + email: googleProfile.email, + }, + }); + + // 이미 존재하는 경우, 로그인 처리 + if (existingUserEntity) { + const user = new User(existingUserEntity); + + const accessToken = AuthTokenManager.buildAccessToken({ userId: user.getId() }); + const refreshToken = AuthTokenManager.buildRefreshToken({ userId: user.getId() }); + await prismaClient.refreshToken.create({ + data: { + userId: user.getId(), + token: refreshToken, + }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.getId(), + email: user.getEmail(), + nickname: user.getNickname(), + image: user.getImage(), + createdAt: user.getCreatedAt(), + updatedAt: user.getUpdatedAt(), + }, + }; + } + + // 존재하지 않는 경우, 회원가입 처리 + if (!existingUserEntity) { + const createdUserEntity = await prismaClient.user.create({ + data: { + email: googleProfile.email, + nickname: googleProfile.nickname, + password: '', // 써드파티 유저의 경우 패스워드가 필요하지 않습니다. + image: googleProfile.image, + }, + }); + + const user = new User(createdUserEntity); + + const accessToken = AuthTokenManager.buildAccessToken({ userId: user.getId() }); + const refreshToken = AuthTokenManager.buildRefreshToken({ userId: user.getId() }); + await prismaClient.refreshToken.create({ + data: { + userId: user.getId(), + token: refreshToken, + }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.getId(), + email: user.getEmail(), + nickname: user.getNickname(), + image: user.getImage(), + createdAt: user.getCreatedAt(), + updatedAt: user.getUpdatedAt(), + }, + }; + } + + throw new InternalServerErrorException('Internal Server Error', ExceptionMessage.GOOGLE_LOGIN_FAILED); + } +} diff --git a/src/application/auth/RefreshTokenHandler.ts b/src/application/auth/RefreshTokenHandler.ts new file mode 100644 index 00000000..957f3aea --- /dev/null +++ b/src/application/auth/RefreshTokenHandler.ts @@ -0,0 +1,46 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { AuthTokenManager } from '../../infra/AuthTokenManager'; + +import { UnprocessableEntityException } from '../../exceptions/UnprocessableEntityException'; +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { User } from '../../domain/User'; + +export class RefreshTokenHandler { + static async handle({ refreshToken } : { refreshToken: string;}) { + if (!AuthTokenManager.isValidRefreshToken(refreshToken)) { + throw new UnprocessableEntityException('Unprocessable Entity', ExceptionMessage.INVALID_REFRESH_TOKEN); + } + + const requester = AuthTokenManager.getRequesterFromToken(`bearer ${refreshToken}`); + + const userEntity = await prismaClient.user.findUnique({ + where: { + id: requester.userId, + }, + }); + if (!userEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.USER_NOT_FOUND); + } + + const refreshTokenEntity = await prismaClient.refreshToken.findFirst({ + where: { + userId: requester.userId, + token: refreshToken, + }, + }); + if (!refreshTokenEntity) { + throw new UnprocessableEntityException('Unprocessable Entity', ExceptionMessage.INVALID_REFRESH_TOKEN); + } + + const user = new User(userEntity); + + return { + accessToken: AuthTokenManager.buildAccessToken({ + userId: user.getId(), + }), + }; + } +} diff --git a/src/application/auth/SignInLocalUserHandler.ts b/src/application/auth/SignInLocalUserHandler.ts new file mode 100644 index 00000000..8bd57a53 --- /dev/null +++ b/src/application/auth/SignInLocalUserHandler.ts @@ -0,0 +1,53 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { AuthTokenManager } from '../../infra/AuthTokenManager'; +import { UserPasswordBuilder } from '../../infra/UserPasswordBuilder'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { User } from '../../domain/User'; + +export class SignInLocalUserHandler { + static async handle({ email, password } : { email: string; password: string;}) { + const userEntity = await prismaClient.user.findUnique({ + where: { + email, + }, + }); + if (!userEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.USER_NOT_FOUND); + } + + const user = new User(userEntity); + + // 패스워드 일치 여부 확인 + if (!user.checkPassword(UserPasswordBuilder.hashPassword(password))) { + // 보안을 위해 비밀번호가 일치하지 않는 경우에도 USER_NOT_FOUND 에러메시지를 반환합니다. + throw new NotFoundException('Not Found', ExceptionMessage.USER_NOT_FOUND); + } + + // 액세스 토큰 및 리프레시 토큰 발급 + const accessToken = AuthTokenManager.buildAccessToken({ userId: user.getId() }); + const refreshToken = AuthTokenManager.buildRefreshToken({ userId: user.getId() }); + await prismaClient.refreshToken.create({ + data: { + userId: user.getId(), + token: refreshToken, + }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.getId(), + email: user.getEmail(), + nickname: user.getNickname(), + image: user.getImage(), + createdAt: user.getCreatedAt(), + updatedAt: user.getUpdatedAt(), + }, + }; + } +} diff --git a/src/application/auth/SignUpLocalUserHandler.ts b/src/application/auth/SignUpLocalUserHandler.ts new file mode 100644 index 00000000..20010ec5 --- /dev/null +++ b/src/application/auth/SignUpLocalUserHandler.ts @@ -0,0 +1,70 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { AuthTokenManager } from '../../infra/AuthTokenManager'; +import { UserPasswordBuilder } from '../../infra/UserPasswordBuilder'; + +import { UnprocessableEntityException } from '../../exceptions/UnprocessableEntityException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { User } from '../../domain/User'; + +type TSignUpLocalUser = { + email: string; + nickname: string; + password: string; + passwordConfirmation: string; +} + +export class SignUpLocalUserHandler { + static async handle({ email, nickname, password, passwordConfirmation } : TSignUpLocalUser) { + // 패스워드 및 패스워드 확인 일치 여부 확인 + if (password !== passwordConfirmation) { + throw new UnprocessableEntityException('Unprocessable Entity', + ExceptionMessage.PASSWORD_CONFIRMATION_NOT_MATCH, + ); + } + + // 이미 존재하는 이메일인지 확인 + const existingUser = await prismaClient.user.findUnique({ + where: { + email, + }, + }); + if (existingUser) { + throw new UnprocessableEntityException('Unprocessable Entity', ExceptionMessage.ALREADY_REGISTERED_EMAIL); + } + + const userEntity = await prismaClient.user.create({ + data: { + email, + nickname, + password: UserPasswordBuilder.hashPassword(password), + }, + }); + + const user = new User(userEntity); + + // 액세스 토큰 및 리프레시 토큰 발급 + const accessToken = AuthTokenManager.buildAccessToken({ userId: user.getId() }); + const refreshToken = AuthTokenManager.buildRefreshToken({ userId: user.getId() }); + await prismaClient.refreshToken.create({ + data: { + userId: user.getId(), + token: refreshToken, + }, + }); + + return { + accessToken, + refreshToken, + user: { + id: user.getId(), + email: user.getEmail(), + nickname: user.getNickname(), + image: user.getImage(), + createdAt: user.getCreatedAt(), + updatedAt: user.getUpdatedAt(), + }, + }; + } +} diff --git a/src/application/comment/DeleteCommentHandler.ts b/src/application/comment/DeleteCommentHandler.ts new file mode 100644 index 00000000..31916c05 --- /dev/null +++ b/src/application/comment/DeleteCommentHandler.ts @@ -0,0 +1,39 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ForbiddenException } from '../../exceptions/ForbiddenException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +type TDeleteCommentUser = { + userId : number; +} + +type TDeleteComment = { + commentId: number; +} + +export class DeleteCommentHandler { + static async handle(requester: TDeleteCommentUser, { commentId } : TDeleteComment) { + await prismaClient.$transaction(async (tx) => { + const targetCommentEntity = await tx.comment.findUnique({ + where: { + id: commentId, + }, + }); + + if (!targetCommentEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.COMMENT_NOT_FOUND); + } + + if (targetCommentEntity.writerId !== requester.userId) { + throw new ForbiddenException('Forbidden', ExceptionMessage.FORBIDDEN); + } + + return await tx.comment.delete({ + where: { + id: commentId, + }, + }); + }); + } +} diff --git a/src/application/comment/UpdateCommentHandler.ts b/src/application/comment/UpdateCommentHandler.ts new file mode 100644 index 00000000..3f1751f1 --- /dev/null +++ b/src/application/comment/UpdateCommentHandler.ts @@ -0,0 +1,73 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ForbiddenException } from '../../exceptions/ForbiddenException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Comment } from '../../domain/Comment'; +import { User } from '../../domain/User'; + +type TUpdateCommentUser = { + userId : number; +} + +type TUpdateComment = { + commentId: number | undefined; + content: string | undefined; +} + +export class UpdateCommentHandler { + static async handle(requester: TUpdateCommentUser, { commentId, content }: TUpdateComment) { + const commentEntity = await prismaClient.$transaction(async (tx) => { + const targetCommentEntity = await tx.comment.findUnique({ + where: { + id: commentId, + }, + }); + + if (!targetCommentEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.COMMENT_NOT_FOUND); + } + + if (targetCommentEntity.writerId !== requester.userId) { + throw new ForbiddenException('Forbidden', ExceptionMessage.FORBIDDEN); + } + + return await tx.comment.update({ + where: { + id: commentId, + }, + data: { + content, + }, + }); + }); + + const comment = new Comment(commentEntity); + + const writerEntity = await prismaClient.user.findUnique({ + where: { + id: comment.getWriterId(), + }, + }); + + if(!writerEntity) { + throw new Error('User Not Found') + } + + const writer = new User(writerEntity); + + return { + id: comment.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + image: writer.getImage(), + }, + articleId: comment.getArticleId(), + productId: comment.getProductId(), + content: comment.getContent(), + createdAt: comment.getCreatedAt(), + }; + } +} diff --git a/src/application/product/CreateProductCommentHandler.ts b/src/application/product/CreateProductCommentHandler.ts new file mode 100644 index 00000000..280151b1 --- /dev/null +++ b/src/application/product/CreateProductCommentHandler.ts @@ -0,0 +1,65 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Comment } from '../../domain/Comment'; +import { User } from '../../domain/User'; +import { TProduct, TProductUser } from '@/types/product'; + +type TCreateProductComment = { + productId: number; + content: string; +} + +export class CreateProductCommentHandler { + static async handle( + requester : TProductUser, + { productId, content } : TCreateProductComment + ) { + const commentEntity = await prismaClient.$transaction(async (tx) => { + const targetProductEntity = await tx.product.findUnique({ + where: { + id: productId, + }, + }); + + if (!targetProductEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.PRODUCT_NOT_FOUND); + } + + return await tx.comment.create({ + data: { + productId: productId, + writerId: requester.userId, + content, + }, + }); + }); + + const comment = new Comment(commentEntity); + + const writerEntity = await prismaClient.user.findUnique({ + where: { + id: comment.getWriterId(), + }, + }); + + if(!writerEntity) { + throw new Error('User Not Found') + } + + const writer = new User(writerEntity); + + return { + id: comment.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + image: writer.getImage(), + }, + content: comment.getContent(), + createdAt: comment.getCreatedAt(), + }; + } +} diff --git a/src/application/product/CreateProductHandler.ts b/src/application/product/CreateProductHandler.ts new file mode 100644 index 00000000..9cc516d2 --- /dev/null +++ b/src/application/product/CreateProductHandler.ts @@ -0,0 +1,40 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { Product } from '../../domain/Product'; +import { Prisma } from '@prisma/client'; +import { TProduct, TProductUser } from '@/types/product'; + +type TCreateProduct = { + name: string; + description: string; + price: number; + tags?: string[] | undefined; + images : string[] | Prisma.ProductCreateimagesInput | undefined; +} +export class CreateProductHandler { + static async handle(requester: TProductUser, { name, description, price, tags, images } : TCreateProduct) { + const productEntity = await prismaClient.product.create({ + data: { + ownerId: requester.userId, + name, + description, + price, + tags, + images, + }, + }); + + const product = new Product(productEntity); + + return { + id: product.getId(), + ownerId: product.getOwnerId(), + name: product.getName(), + description: product.getDescription(), + price: product.getPrice(), + tags: product.getTags(), + images: product.getImages(), + createdAt: product.getCreatedAt(), + }; + } +} diff --git a/src/application/product/CreateProductLikeHandler.ts b/src/application/product/CreateProductLikeHandler.ts new file mode 100644 index 00000000..1dd89f4c --- /dev/null +++ b/src/application/product/CreateProductLikeHandler.ts @@ -0,0 +1,61 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Product } from '../../domain/Product'; +import { TProduct, TProductUser } from '@/types/product'; + +type TCreateProductLike = { + productId: number; +} + +export class CreateProductLikeHandler { + static async handle(requester: TProductUser, { productId } : TCreateProductLike) { + const productEntity = await prismaClient.$transaction(async (tx) => { + const targetProductEntity = await tx.product.findUnique({ + where: { + id: productId, + }, + }); + + if (!targetProductEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.PRODUCT_NOT_FOUND); + } + + const likeEntity = await tx.like.findUnique({ + where: { + userId_productId: { + userId: requester.userId, + productId, + }, + }, + }); + + if (!likeEntity) { + await tx.like.create({ + data: { + productId: Number(productId), + userId: requester.userId, + }, + }); + } + + return targetProductEntity; + }); + + const product = new Product(productEntity); + + return { + id: product.getId(), + ownerId: product.getOwnerId(), + name: product.getName(), + description: product.getDescription(), + price: product.getPrice(), + tags: product.getTags(), + images: product.getImages(), + createdAt: product.getCreatedAt(), + isFavorite: true, + }; + } +} diff --git a/src/application/product/DeleteProductHandler.ts b/src/application/product/DeleteProductHandler.ts new file mode 100644 index 00000000..1ca913e2 --- /dev/null +++ b/src/application/product/DeleteProductHandler.ts @@ -0,0 +1,37 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ForbiddenException } from '../../exceptions/ForbiddenException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; +import { TProduct, TProductUser } from '@/types/product'; + +type TDeleteProduct = { + productId: number; +} + + +export class DeleteProductHandler { + static async handle(requester : TProductUser, { productId } : TDeleteProduct) { + await prismaClient.$transaction(async (tx) => { + const targetProductEntity = await tx.product.findUnique({ + where: { + id: Number(productId), + }, + }); + + if (!targetProductEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.PRODUCT_NOT_FOUND); + } + + if (targetProductEntity.ownerId !== requester.userId) { + throw new ForbiddenException('Forbidden', ExceptionMessage.FORBIDDEN); + } + + return await tx.product.delete({ + where: { + id: Number(productId), + }, + }); + }); + } +} diff --git a/src/application/product/DeleteProductLikeHandler.ts b/src/application/product/DeleteProductLikeHandler.ts new file mode 100644 index 00000000..bfb0da8e --- /dev/null +++ b/src/application/product/DeleteProductLikeHandler.ts @@ -0,0 +1,63 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Product } from '../../domain/Product'; +import { TProduct, TProductUser } from '@/types/product'; + +type TDeleteProductLike = { + productId: number; +} + +export class DeleteProductLikeHandler { + static async handle(requester : TProductUser, { productId }: TDeleteProductLike) { + const productEntity = await prismaClient.$transaction(async (tx) => { + const targetProductEntity = await tx.product.findUnique({ + where: { + id: productId, + }, + }); + + if (!targetProductEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.PRODUCT_NOT_FOUND); + } + + const likeEntity = await tx.like.findUnique({ + where: { + userId_productId: { + userId: requester.userId, + productId, + }, + }, + }); + + if (likeEntity) { + await tx.like.delete({ + where: { + userId_productId: { + userId: requester.userId, + productId, + }, + }, + }); + } + + return targetProductEntity; + }); + + const product = new Product(productEntity); + + return { + id: product.getId(), + ownerId: product.getOwnerId(), + name: product.getName(), + description: product.getDescription(), + price: product.getPrice(), + tags: product.getTags(), + images: product.getImages(), + createdAt: product.getCreatedAt(), + isFavorite: false, + }; + } +} diff --git a/src/application/product/GetProductCommentListHandler.ts b/src/application/product/GetProductCommentListHandler.ts new file mode 100644 index 00000000..fe909a15 --- /dev/null +++ b/src/application/product/GetProductCommentListHandler.ts @@ -0,0 +1,79 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Comment } from '../../domain/Comment'; +import { User } from '../../domain/User'; +import { Prisma } from '@prisma/client'; + +type TGetProductCommentList = { + productId: number; + cursor?: number | undefined + limit: number | undefined; +} + +export class GetProductCommentListHandler { + static async handle({ productId, cursor, limit } : TGetProductCommentList) { + const commentEntities = await prismaClient.$transaction(async (tx) => { + const targetProductEntity = await tx.product.findUnique({ + where: { + id: Number(productId), + }, + }); + + if (!targetProductEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.PRODUCT_NOT_FOUND); + } + + return await tx.comment.findMany({ + cursor: cursor + ? { + id: cursor, + } + : undefined, + take: Number(limit) + 1, + where: { + productId: Number(productId), + }, + }); + }); + + const comments = commentEntities.map((commentEntity) => new Comment(commentEntity)); + + const writerEntities = await prismaClient.user.findMany({ + where: { + id: { + in: Array.from(new Set(comments.map((comment) => comment.getWriterId()))), + }, + }, + }); + + const writers = writerEntities.map((writerEntity) => new User(writerEntity)); + const hasNext = comments.length === Number(limit) + 1; + + return { + list: comments.slice(0, limit).map((comment) => { + const writer = writers.find((writer) => writer.getId() === comment.getWriterId()); + + if(!writer) { + throw new Error('Writer not found'); + } + + return { + id: comment.getId(), + writer: { + id: writer.getId(), + nickname: writer.getNickname(), + image: writer.getImage(), + }, + productId: comment.getProductId(), + content: comment.getContent(), + createdAt: comment.getCreatedAt(), + updatedAt: comment.getUpdatedAt(), + }; + }), + nextCursor: hasNext ? comments[comments.length - 1].getId() : null, + }; + } +} diff --git a/src/application/product/GetProductHandler.ts b/src/application/product/GetProductHandler.ts new file mode 100644 index 00000000..56f2ffdc --- /dev/null +++ b/src/application/product/GetProductHandler.ts @@ -0,0 +1,49 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Product } from '../../domain/Product'; +import { TProduct, TProductUser } from '@/types/product'; + +type TGetProduct = { + productId: number; +} + +export class GetProductHandler { + static async handle(requester : TProductUser, { productId } : TGetProduct) { + const productEntity = await prismaClient.product.findUnique({ + where: { + id: Number(productId), + }, + include: { + likes: { + select: { + // 좋아요의 id, userId만 필요함 + id: true, + userId: true, + }, + }, + }, + }); + + if (!productEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.PRODUCT_NOT_FOUND); + } + + const product = new Product(productEntity); + + return { + id: product.getId(), + ownerId: product.getOwnerId(), + name: product.getName(), + description: product.getDescription(), + price: product.getPrice(), + tags: product.getTags(), + images: product.getImages(), + createdAt: product.getCreatedAt(), + favoriteCount: product.getFavoriteCount(), + isFavorite: product.getIsFavorite(requester.userId), + }; + } +} diff --git a/src/application/product/GetProductListHandler.ts b/src/application/product/GetProductListHandler.ts new file mode 100644 index 00000000..940f84ee --- /dev/null +++ b/src/application/product/GetProductListHandler.ts @@ -0,0 +1,85 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { Product } from '../../domain/Product'; +import { Like } from '../../domain/Like'; +import { Prisma } from '@prisma/client'; + +type TGetProductListUser = { + userId : number; +} + +type TGetProductList = { + page: number; + pageSize: number; + orderBy: 'favorite' | 'recent'; + keyword: string | undefined; +} + +export class GetProductListHandler { + static async handle(requester : TGetProductListUser, { page, pageSize, orderBy, keyword } : TGetProductList) { + const whereClause = keyword + ? { + OR: [ + { + name: { + contains: keyword, + }, + }, + { + description: { + contains: keyword, + }, + }, + ], + } + : undefined; + + const matchedProductCount = await prismaClient.product.count({ + where: whereClause, + }); + + const orderByOption = + orderBy === 'favorite' + ? { _count: { likes: 'desc' as Prisma.SortOrder } } + : { createdAt: 'desc' as Prisma.SortOrder }; + + const productEntities = await prismaClient.product.findMany({ + skip: pageSize * (page - 1), + take: pageSize, + where: whereClause, + orderBy: orderByOption, + include: { + _count: { + select: { likes: true }, // 각 Product의 전체 Like 개수 + }, + likes: { + select: { + // 좋아요의 id, userId만 필요함 + id: true, + userId: true, + }, + }, + }, + }); + + const products = productEntities.map( + (productEntity) => new Product(productEntity) + ); + + return { + totalCount: matchedProductCount, + list: products.slice(0, pageSize).map((product) => ({ + id: product.getId(), + ownerId: product.getOwnerId(), + name: product.getName(), + description: product.getDescription(), + price: product.getPrice(), + tags: product.getTags(), + images: product.getImages(), + createdAt: product.getCreatedAt(), + favoriteCount: product.getFavoriteCount(), + isFavorite: product.getIsFavorite(requester.userId), + })), + }; + } +} diff --git a/src/application/product/UpdateProductHandler.ts b/src/application/product/UpdateProductHandler.ts new file mode 100644 index 00000000..fddf09f4 --- /dev/null +++ b/src/application/product/UpdateProductHandler.ts @@ -0,0 +1,63 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ForbiddenException } from '../../exceptions/ForbiddenException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Product } from '../../domain/Product'; +import { TProductUser } from '@/types/product'; + +type TUpdateProduct = { + productId: number; + name: string | undefined; + description: string | undefined; + price: number | undefined; + tags: string[] | undefined; + images: string[] | undefined; +} + +export class UpdateProductHandler { + static async handle(requester: TProductUser, { productId, name, description, price, tags, images } : TUpdateProduct) { + const productEntity = await prismaClient.$transaction(async (tx) => { + const targetProductEntity = await tx.product.findUnique({ + where: { + id: Number(productId), + }, + }); + + if (!targetProductEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.PRODUCT_NOT_FOUND); + } + + if (targetProductEntity.ownerId !== requester.userId) { + throw new ForbiddenException('Forbidden', ExceptionMessage.FORBIDDEN); + } + + return await tx.product.update({ + where: { + id: Number(productId), + }, + data: { + name, + description, + price, + tags, + images, + }, + }); + }); + + const product = new Product(productEntity); + + return { + id: product.getId(), + ownerId: product.getOwnerId(), + name: product.getName(), + description: product.getDescription(), + price: product.getPrice(), + tags: product.getTags(), + images: product.getImages(), + createdAt: product.getCreatedAt(), + }; + } +} diff --git a/src/application/user/GetUserFavoriteListHandler.ts b/src/application/user/GetUserFavoriteListHandler.ts new file mode 100644 index 00000000..8e6ba772 --- /dev/null +++ b/src/application/user/GetUserFavoriteListHandler.ts @@ -0,0 +1,78 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Product } from '../../domain/Product'; + +type TGetUserFavoriteListUser = { + userId: number; +} + +type TGetUserFavoriteList = { + page: number; + pageSize: number; + keyword: string | undefined; +} + +export class GetUserFavoriteListHandler { + static async handle(requester: TGetUserFavoriteListUser, { page, pageSize, keyword }: TGetUserFavoriteList) { + const userEntity = await prismaClient.user.findUnique({ + where: { + id: requester.userId, + }, + }); + + if (!userEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.USER_NOT_FOUND); + } + + const favoriteProductCount = await prismaClient.product.count({ + where: { + likes: { + some: { + userId: requester.userId, + }, + }, + name: { + contains: keyword, + }, + }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + const favoriteProductsEntities = await prismaClient.product.findMany({ + where: { + likes: { + some: { + userId: requester.userId, + }, + }, + name: { + contains: keyword, + }, + }, + skip: (page - 1) * pageSize, + take: pageSize, + }); + + const favoriteProducts = favoriteProductsEntities.map( + (favoriteProductEntity) => new Product(favoriteProductEntity), + ); + + return { + totalCount: favoriteProductCount, + list: favoriteProducts.map((product) => ({ + id: product.getId(), + ownerId: product.getOwnerId(), + name: product.getName(), + description: product.getDescription(), + price: product.getPrice(), + tags: product.getTags(), + images: product.getImages(), + createdAt: product.getCreatedAt(), + })), + }; + } +} diff --git a/src/application/user/GetUserProductListHandler.ts b/src/application/user/GetUserProductListHandler.ts new file mode 100644 index 00000000..467fbbdd --- /dev/null +++ b/src/application/user/GetUserProductListHandler.ts @@ -0,0 +1,65 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { Product } from '../../domain/Product'; + +type TGetUserProductListUser = { + userId : number; +} + +type TGetUserProductList = { + page: number | undefined; + pageSize: number | undefined; + keyword?: string; +} +export class GetUserProductListHandler { + static async handle(requester: TGetUserProductListUser, { page, pageSize, keyword } : TGetUserProductList) { + const userEntity = await prismaClient.user.findUnique({ + where: { + id: requester.userId, + }, + }); + if (!userEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.USER_NOT_FOUND); + } + + const productCount = await prismaClient.product.count({ + where: { + ownerId: requester.userId, + name: { + contains: keyword, + }, + }, + }); + + const productEntities = await prismaClient.product.findMany({ + where: { + ownerId: requester.userId, + name: { + contains: keyword, + }, + }, + skip: (Number(page) - 1) * Number(pageSize), + take: pageSize, + }); + + const products = productEntities.map((productEntity) => new Product(productEntity)); + + return { + totalCount: productCount, + list: products.map((product) => ({ + id: product.getId(), + ownerId: product.getOwnerId(), + name: product.getName(), + description: product.getDescription(), + price: product.getPrice(), + tags: product.getTags(), + images: product.getImages(), + createdAt: product.getCreatedAt(), + updatedAt: product.getUpdatedAt(), + })), + }; + } +} diff --git a/src/application/user/GetUserProfileHandler.ts b/src/application/user/GetUserProfileHandler.ts new file mode 100644 index 00000000..177df1bb --- /dev/null +++ b/src/application/user/GetUserProfileHandler.ts @@ -0,0 +1,34 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { User } from '../../domain/User'; + +type TGetUserProfile = { + userId : number; +} + +export class GetUserProfileHandler { + static async handle(requester : TGetUserProfile) { + const userEntity = await prismaClient.user.findUnique({ + where: { + id: requester.userId, + }, + }); + if (!userEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.USER_NOT_FOUND); + } + + const user = new User(userEntity); + + return { + id: user.getId(), + email: user.getEmail(), + nickname: user.getNickname(), + image: user.getImage(), + createdAt: user.getCreatedAt(), + updatedAt: user.getUpdatedAt(), + }; + } +} diff --git a/src/application/user/UpdateUserPasswordHandler.ts b/src/application/user/UpdateUserPasswordHandler.ts new file mode 100644 index 00000000..d95e303d --- /dev/null +++ b/src/application/user/UpdateUserPasswordHandler.ts @@ -0,0 +1,70 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { UserPasswordBuilder } from '../../infra/UserPasswordBuilder'; + +import { UnprocessableEntityException } from '../../exceptions/UnprocessableEntityException'; +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { User } from '../../domain/User'; + +type TUpdateUserPasswordUser = { + userId : number; +} + +type TUpdateUserPassword = { + password: string; + passwordConfirmation: string; + currentPassword: string; +} + + +export class UpdateUserPasswordHandler { + static async handle(requester: TUpdateUserPasswordUser, { password, passwordConfirmation, currentPassword } : TUpdateUserPassword) { + // 패스워드와 패스워드 확인이 일치하는지 검증 + if (password !== passwordConfirmation) { + throw new UnprocessableEntityException('Unprocessable Entity', + ExceptionMessage.PASSWORD_CONFIRMATION_NOT_MATCH, + ); + } + + const userEntity = await prismaClient.user.findUnique({ + where: { + id: requester.userId, + }, + }); + if (!userEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.USER_NOT_FOUND); + } + + const user = new User(userEntity); + + // 현재 패스워드가 일치하는지 검증 + if (!user.checkPassword(UserPasswordBuilder.hashPassword(currentPassword))) { + throw new UnprocessableEntityException('Unprocessable Entity', ExceptionMessage.CURRENT_PASSWORD_NOT_MATCH); + } + + // 비밀번호 변경 진행 + const hashedPassword = UserPasswordBuilder.hashPassword(password); + user.setPassword(hashedPassword); + await prismaClient.user.update({ + where: { + id: user.getId(), + }, + data: { + password: hashedPassword, + }, + }); + + return { + user: { + id: user.getId(), + email: user.getEmail(), + nickname: user.getNickname(), + image: user.getImage(), + createdAt: user.getCreatedAt(), + updatedAt: user.getUpdatedAt(), + }, + }; + } +} diff --git a/src/application/user/UpdateUserProfileHandler.ts b/src/application/user/UpdateUserProfileHandler.ts new file mode 100644 index 00000000..60fa91e8 --- /dev/null +++ b/src/application/user/UpdateUserProfileHandler.ts @@ -0,0 +1,46 @@ +import { prismaClient } from '../../infra/prismaClient'; + +import { NotFoundException } from '../../exceptions/NotFoundException'; +import { ExceptionMessage } from '../../constant/ExceptionMessage'; + +import { User } from '../../domain/User'; + +type TUpdateUserProfileUser = { + userId: number; +} + +export class UpdateUserProfileHandler { + static async handle(requester: TUpdateUserProfileUser, { image }: { image?: string | null }) { + const userEntity = await prismaClient.user.findUnique({ + where: { + id: requester.userId, + }, + }); + if (!userEntity) { + throw new NotFoundException('Not Found', ExceptionMessage.USER_NOT_FOUND); + } + + const user = new User(userEntity); + user.setImage(image); + + await prismaClient.user.update({ + where: { + id: user.getId(), + }, + data: { + image: user.getImage(), + }, + }); + + return { + user: { + id: user.getId(), + email: user.getEmail(), + nickname: user.getNickname(), + image: user.getImage(), + createdAt: user.getCreatedAt(), + updatedAt: user.getUpdatedAt(), + }, + }; + } +} diff --git a/src/constant/ExceptionMessage.ts b/src/constant/ExceptionMessage.ts new file mode 100644 index 00000000..3aa8cd29 --- /dev/null +++ b/src/constant/ExceptionMessage.ts @@ -0,0 +1,22 @@ +/** + * [에러 메시지 상수] + * + * 에러 메시지가 반복적으로 사용되는 경우, 상수로 관리하는 것이 효율적입니다. + * + * 여러 장점들: + * - 오타 방지 + * - 추후 에러메시지 변경에 유리 + * - ... + */ +export const ExceptionMessage = { + ARTICLE_NOT_FOUND: '게시글을 찾을 수 없습니다', + PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다', + COMMENT_NOT_FOUND: '댓글을 찾을 수 없습니다', + USER_NOT_FOUND: '사용자를 찾을 수 없습니다', + PASSWORD_CONFIRMATION_NOT_MATCH: '비밀번호 확인이 일치하지 않습니다', + CURRENT_PASSWORD_NOT_MATCH: '현재 비밀번호가 일치하지 않습니다', + ALREADY_REGISTERED_EMAIL: '이미 등록된 이메일입니다', + FORBIDDEN: '접근이 금지되었습니다', + INVALID_REFRESH_TOKEN: '유효하지 않은 리프레시 토큰입니다', + GOOGLE_LOGIN_FAILED: '구글 로그인에 실패하였습니다', +}; diff --git a/src/domain/Article.ts b/src/domain/Article.ts new file mode 100644 index 00000000..ccc28de8 --- /dev/null +++ b/src/domain/Article.ts @@ -0,0 +1,77 @@ +import { TArticleParam } from "@/types/article"; +import { TLikeParam } from "@/types/like"; + +export class Article { + /** ID */ + _id; + + /** 작성자 ID */ + _writerId; + + /** 제목 */ + _title; + + /** 내용 */ + _content; + + /** 이미지 */ + _image; + + /** 작성시각 */ + _createdAt; + + /** 마지막 수정시각 */ + _updatedAt; + + /** 좋아요 수 **/ + _likes: TLikeParam[]; + + constructor(param: TArticleParam) { + this._id = param.id; + this._writerId = param.writerId; + this._title = param.title; + this._content = param.content; + this._image = param.image; + this._createdAt = param.createdAt; + this._updatedAt = param.updatedAt; + this._likes = param.likes ?? []; + } + + getId() { + return this._id; + } + + getWriterId() { + return this._writerId; + } + + getTitle() { + return this._title; + } + + getContent() { + return this._content; + } + + getImage() { + return this._image; + } + + getCreatedAt() { + return this._createdAt; + } + + getUpdatedAt() { + return this._updatedAt; + } + + getIsFavorite(userId: number) { + if (!userId) return false; + + return this._likes.some((like) => like.userId === userId); + } + + getFavoriteCount() { + return this._likes.length; + } +} diff --git a/src/domain/Comment.ts b/src/domain/Comment.ts new file mode 100644 index 00000000..0f88558f --- /dev/null +++ b/src/domain/Comment.ts @@ -0,0 +1,62 @@ +import { TCommentParam } from "@/types/comment"; + +export class Comment { + /** ID */ + _id; + + /** 작성자 ID */ + _writerId; + + /** 게시글 ID */ + _articleId; + + /** 상품 ID */ + _productId; + + /** 내용 */ + _content; + + /** 작성시각 */ + _createdAt; + + /** 마지막 수정시각 */ + _updatedAt; + + constructor(param: TCommentParam) { + this._id = param.id; + this._writerId = param.writerId; + this._articleId = param.articleId; + this._productId = param.productId; + this._content = param.content; + this._createdAt = param.createdAt; + this._updatedAt = param.updatedAt; + } + + getId() { + return this._id; + } + + getWriterId() { + return this._writerId; + } + + getArticleId() { + return this._articleId; + } + + getProductId() { + return this._productId; + } + + getContent() { + return this._content; + } + + getCreatedAt() { + return this._createdAt; + } + + getUpdatedAt() { + return this._updatedAt; + } +} diff --git a/src/domain/Like.ts b/src/domain/Like.ts new file mode 100644 index 00000000..f61b2d0d --- /dev/null +++ b/src/domain/Like.ts @@ -0,0 +1,46 @@ +import { TLikeParam } from "@/types/like"; + +export class Like { + /** ID */ + _id; + + /** 사용자 ID */ + _userId; + + /** 상품 ID */ + _productId; + + /** 게시글 ID */ + _articleId; + + /** 생성시각 */ + _createdAt; + + constructor(param: TLikeParam) { + this._id = param.id; + this._userId = param.userId; + this._productId = param.productId; + this._articleId = param.articleId; + this._createdAt = param.createdAt; + } + + getId() { + return this._id; + } + + getUserId() { + return this._userId; + } + + getProductId() { + return this._productId; + } + + getArticleId() { + return this._articleId; + } + + getCreatedAt() { + return this._createdAt; + } +} diff --git a/src/domain/Product.ts b/src/domain/Product.ts new file mode 100644 index 00000000..9812ac69 --- /dev/null +++ b/src/domain/Product.ts @@ -0,0 +1,92 @@ +import { TProductParam } from "@/types/product"; + +export class Product { + /** ID */ + _id; + + /** 작성자 ID */ + _ownerId; + + /** 상품명 */ + _name; + + /** 상품 설명 */ + _description; + + /** 판매 가격 */ + _price; + + /** 해시 태그 목록 */ + _tags; + + /** 이미지 목록 */ + _images; + + /** 생성시각 */ + _createdAt; + + /** 마지막 수정시각 */ + _updatedAt; + + /** 좋아요 목록 */ + _likes; + + constructor(param : TProductParam) { + this._id = param.id; + this._ownerId = param.ownerId; + this._name = param.name; + this._description = param.description; + this._price = param.price; + this._tags = Array.from(param.tags); // 깊은 복사를 통해, 외부의 배열을 통해 내부 배열을 변경할 수 없도록 합니다. + this._images = Array.from(param.images); + this._createdAt = param.createdAt; + this._updatedAt = param.updatedAt; + this._likes = param.likes ?? []; + } + + getId() { + return this._id; + } + + getOwnerId() { + return this._ownerId; + } + + getName() { + return this._name; + } + + getDescription() { + return this._description; + } + + getPrice() { + return this._price; + } + + getTags() { + return Array.from(this._tags); // 깊은 복사를 통해, 반환된 배열을 통해 내부 배열을 변경할 수 없도록 합니다. + } + + getImages() { + return Array.from(this._images); + } + + getCreatedAt() { + return this._createdAt; + } + + getUpdatedAt() { + return this._updatedAt; + } + + getIsFavorite(userId: number) { + if (!userId) return false; + + return this._likes.some((like) => like.userId === userId); + } + + getFavoriteCount() { + return this._likes.length; + } +} diff --git a/src/domain/User.ts b/src/domain/User.ts new file mode 100644 index 00000000..2c58f6e1 --- /dev/null +++ b/src/domain/User.ts @@ -0,0 +1,70 @@ +import { TUserParam } from "@/types/user"; + +export class User { + /** ID */ + _id; + + /** 이메일 */ + _email; + + /** 비밀번호 */ + _password; + + /** 닉네임 */ + _nickname; + + /** 이미지 */ + _image; + + /** 생성시각 */ + _createdAt; + + /** 마지막 수정시각 */ + _updatedAt; + + constructor(param: TUserParam) { + this._id = param.id; + this._email = param.email; + this._password = param.password; + this._nickname = param.nickname; + this._image = param.image; + this._createdAt = param.createdAt; + this._updatedAt = param.updatedAt; + } + + getId() { + return this._id; + } + + getEmail() { + return this._email; + } + + getNickname() { + return this._nickname; + } + + getImage() { + return this._image; + } + + getCreatedAt() { + return this._createdAt; + } + + getUpdatedAt() { + return this._updatedAt; + } + + setImage(image?: string | null) { + this._image = image; + } + + setPassword(password: string) { + this._password = password; + } + + checkPassword(password: string) { + return this._password === password; + } +} diff --git a/src/exceptions/BadRequestException.ts b/src/exceptions/BadRequestException.ts new file mode 100644 index 00000000..0423f69c --- /dev/null +++ b/src/exceptions/BadRequestException.ts @@ -0,0 +1,11 @@ +import { HttpException } from './HttpException'; + +export class BadRequestException extends HttpException { + constructor(name: string, message: string) { + super({ + status: 400, + name, + message, + }); + } +} diff --git a/src/exceptions/ForbiddenException.ts b/src/exceptions/ForbiddenException.ts new file mode 100644 index 00000000..40c070cf --- /dev/null +++ b/src/exceptions/ForbiddenException.ts @@ -0,0 +1,11 @@ +import { HttpException } from './HttpException'; + +export class ForbiddenException extends HttpException { + constructor(name: string, message: string) { + super({ + status: 403, + name, + message, + }); + } +} diff --git a/src/exceptions/HttpException.ts b/src/exceptions/HttpException.ts new file mode 100644 index 00000000..fffd47cb --- /dev/null +++ b/src/exceptions/HttpException.ts @@ -0,0 +1,27 @@ +/** + * [일관성있는 에러처리를 위한 HttpException 클래스] + * + * 해당 서버에서 발생하는 모든 에러는 HttpException 으로 변환되어 응답되어야 합니다. + * 자세한 내용은 아래 코드를 참고하세요. + * + * @see asyncErrorHandler + */ + +type HttpExceptionParam = { + status: number; + name: string; + message: string; +}; + + +export class HttpException extends Error { + status; + name; + + constructor(param: HttpExceptionParam) { + const { status, name, message } = param; + super(message); + this.status = status; + this.name = name; + } +} diff --git a/src/exceptions/InternalServerErrorException.ts b/src/exceptions/InternalServerErrorException.ts new file mode 100644 index 00000000..42581183 --- /dev/null +++ b/src/exceptions/InternalServerErrorException.ts @@ -0,0 +1,11 @@ +import { HttpException } from './HttpException'; + +export class InternalServerErrorException extends HttpException { + constructor(name: string, message: string) { + super({ + status: 500, + name, + message, + }); + } +} diff --git a/src/exceptions/NotFoundException.ts b/src/exceptions/NotFoundException.ts new file mode 100644 index 00000000..1fde801a --- /dev/null +++ b/src/exceptions/NotFoundException.ts @@ -0,0 +1,11 @@ +import { HttpException } from './HttpException'; + +export class NotFoundException extends HttpException { + constructor(name: string, message: string) { + super({ + status: 404, + name, + message, + }); + } +} diff --git a/src/exceptions/UnprocessableEntityException.ts b/src/exceptions/UnprocessableEntityException.ts new file mode 100644 index 00000000..0ce6068a --- /dev/null +++ b/src/exceptions/UnprocessableEntityException.ts @@ -0,0 +1,11 @@ +import { HttpException } from '../exceptions/HttpException'; + +export class UnprocessableEntityException extends HttpException { + constructor(name: string, message: string) { + super({ + status: 422, + name, + message, + }); + } +} diff --git a/src/infra/AuthTokenManager.ts b/src/infra/AuthTokenManager.ts new file mode 100644 index 00000000..5e79a7ce --- /dev/null +++ b/src/infra/AuthTokenManager.ts @@ -0,0 +1,92 @@ +import jwt from 'jsonwebtoken'; + +type TisValidAccessToken = { + accessToken: string | null; +} + +export class AuthTokenManager { + /** + * 현재 시각으로부터 1시간동안 유효한 액세스 토큰을 생성합니다. + */ + static buildAccessToken(payload: any) { + return jwt.sign( + { + user: { + id: payload.userId, + }, + }, + process.env.JWT_ACCESS_TOKEN_SECRET!, + { + expiresIn: '1h', + }, + ); + } + + /** + * 주어진 액세스 토큰이 유효한지 검증합니다. + */ + static isValidAccessToken(accessToken : string) { + try { + jwt.verify(accessToken, process.env.JWT_ACCESS_TOKEN_SECRET!); + + return true; + } catch (e) { + return false; + } + } + + /** + * 현재 시각으로부터 14일동안 유효한 리프레시 토큰을 생성합니다. + */ + static buildRefreshToken(payload: any) { + return jwt.sign( + { + user: { + id: payload.userId, + }, + }, + process.env.JWT_REFRESH_TOKEN_SECRET!, + { + expiresIn: '14d', + }, + ); + } + + static isValidRefreshToken(refreshToken: string) { + try { + jwt.verify(refreshToken, process.env.JWT_REFRESH_TOKEN_SECRET!); + + return true; + } catch (e) { + return false; + } + } + + /** + * 액세스 토큰 또는 리프래시 토큰으로부터 요청자 정보를 추출합니다. + */ + static getRequesterFromToken(authorizationHeaderValue: any) { + const jwtToken = authorizationHeaderValue.split(' ')[1]; // "bearer JWT_TOKEN" 형태로 전달받음 + + const jwtPayload = jwt.decode(jwtToken); + + + if (!jwtPayload || typeof jwtPayload === 'string') { + throw new Error('Invalid JWT payload'); + } + + return { + userId: jwtPayload.user.id, + }; + } + + static getRequesterFromTokenOrDefault(authorizationHeaderValue: string) { + try { + return this.getRequesterFromToken(authorizationHeaderValue); + } catch (e) { + return { + userId: -1, // GUEST + }; + } + } +} diff --git a/src/infra/GoogleOAuthAdapter.ts b/src/infra/GoogleOAuthAdapter.ts new file mode 100644 index 00000000..986144db --- /dev/null +++ b/src/infra/GoogleOAuthAdapter.ts @@ -0,0 +1,60 @@ +import * as Axios from 'axios'; + + +class GoogleOAuthAdapter { + _httpClient = Axios.default.create(); + + + /** + * Google Consent Screen (구글의 로그인 페이지)으로 가는 URI를 리턴합니다. + * 이 URI로 클라이언트 웹 브라우저를 리다이렉트 시키는 용도입니다. + */ + generateAuthURI() { + const searchParams = new URLSearchParams({ + client_id: process.env.GOOGLE_CLIENT_ID!, + redirect_uri: process.env.GOOGLE_REDIRECT_URI!, + response_type: 'code', + scope: 'email profile', + access_type: 'offline', + } ) + const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${searchParams.toString()}` + return authUrl; + } + + /** + * 구글 OAuth AccessToken 발급을 요청합니다. + */ + async getAccessToken(code : string) { + const response = await this._httpClient.post('https://oauth2.googleapis.com/token', { + code, + client_id: process.env.GOOGLE_CLIENT_ID, + client_secret: process.env.GOOGLE_CLIENT_SECRET, + redirect_uri: process.env.GOOGLE_REDIRECT_URI, + grant_type: 'authorization_code', + }); + + return response.data.access_token; + } + + /** + * 구글 OAuth AccessToken을 이용해 사용자 프로필 정보를 가져옵니다. + */ + async getProfile(accessToken : string) { + const response = await this._httpClient.get( + 'https://www.googleapis.com/oauth2/v2/userinfo', + { + params: { access_token: accessToken }, + }, + ); + + const { email, name, picture } = response.data; + + return { + email, + nickname: name, + image: picture || null, + }; + } +} + +export const googleOAuthHelper = new GoogleOAuthAdapter(); diff --git a/src/infra/UserPasswordBuilder.ts b/src/infra/UserPasswordBuilder.ts new file mode 100644 index 00000000..c2cdea92 --- /dev/null +++ b/src/infra/UserPasswordBuilder.ts @@ -0,0 +1,10 @@ +import crypto from 'crypto'; + +export class UserPasswordBuilder { + /** + * 보안상의 이유로 사용자 비밀번호를 해싱합니다. + */ + static hashPassword(password : string) { + return crypto.createHash('sha512').update(password).digest('base64'); + } +} diff --git a/src/infra/prismaClient.ts b/src/infra/prismaClient.ts new file mode 100644 index 00000000..5e41d6fe --- /dev/null +++ b/src/infra/prismaClient.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client'; + +export const prismaClient = new PrismaClient(); diff --git a/src/interface/ArticleRouter.ts b/src/interface/ArticleRouter.ts new file mode 100644 index 00000000..d25aecb0 --- /dev/null +++ b/src/interface/ArticleRouter.ts @@ -0,0 +1,216 @@ +import express, { Request, Response } from 'express'; +import { create } from 'superstruct'; +import { AuthN } from './utils/AuthN'; + +import { AuthTokenManager } from '../infra/AuthTokenManager'; + +import { asyncErrorHandler } from './utils/asyncErrorHandler'; +import { CreateArticleRequestStruct } from './structs/article/CreateArticleRequestStruct'; +import { UpdateArticleRequestStruct } from './structs/article/UpdateArticleRequestStruct'; +import { GetArticleListRequestStruct } from './structs/article/GetArticleListRequestStruct'; +import { CreateCommentRequestStruct } from './structs/comment/CreateCommentRequestStruct'; +import { GetCommentListRequestStruct } from './structs/comment/GetCommentListRequestStruct'; + +import { CreateArticleHandler } from '../application/article/CreateArticleHandler'; +import { GetArticleHandler } from '../application/article/GetArticleHandler'; +import { UpdateArticleHandler } from '../application/article/UpdateArticleHandler'; +import { DeleteArticleHandler } from '../application/article/DeleteArticleHandler'; +import { GetArticleListHandler } from '../application/article/GetArticleListHandler'; +import { CreateArticleCommentHandler } from '../application/article/CreateArticleCommentHandler'; +import { GetArticleCommentListHandler } from '../application/article/GetArticleCommentListHandler'; +import { CreateArticleLikeHandler } from '../application/article/CreateArticleLikeHandler'; +import { DeleteArticleLikeHandler } from '../application/article/DeleteArticleLikeHandler'; + +import { TArticle } from '@/types/article'; + +export const ArticleRouter = express.Router(); + +type ArticleRouterQuery = { + cursor?: string; + limit?: string | undefined; + orderBy: "recent" | "favorite"; + keyword: string | undefined; +} + +// 게시글 등록 api +ArticleRouter.post( + '/', + AuthN(), + asyncErrorHandler(async (req:Request<{}, {}, TArticle>, res:Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + /** + * [API 요청 유효성 검사] + * + * assert 메서드는 유효성 검사만 시도하는데 비해, + * create 메서드는 데이터를 전처리하고, 유효성 검사를 같이 시도합니다. + * + * 전처리를 하는 이유는 아래와 같이 다양합니다. + * - 기본값을 설정하기 위해 @see GetArticleListRequestStruct + * - 데이터를 변환하기 위해 + * 1. 문자열 앞뒤에 있는 공백 제거 @see CreateArticleRequestStruct + * 2. 문자열로 이루어진 숫자 -> 숫자 @see GetArticleListRequestStruct + * ... + */ + const { title, content, image } = create(req.body, CreateArticleRequestStruct); + + const articleView = await CreateArticleHandler.handle(requester, { + title, + content, + image, + }); + + return res.status(201).send(articleView); + }), +); + +// 게시글 조회 api +ArticleRouter.get( + '/:articleId', + asyncErrorHandler(async (req: Request< {articleId: number} >, res: Response) => { + const requester = AuthTokenManager.getRequesterFromTokenOrDefault( + req.headers.authorization!, + ); + + const articleId = Number(req.params.articleId); + + if(!articleId) { + throw new Error('게시글을 찾을 수 없습니다.') + } + + const articleView = await GetArticleHandler.handle(requester, { + articleId, + }); + + res.status(201).send(articleView); + }), +); + +// 게시글 수정 api +ArticleRouter.patch( + '/:articleId', + AuthN(), + asyncErrorHandler(async (req:Request< {articleId: TArticle;} >, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { articleId } = req.params; + const { title, content, image } = create(req.body, UpdateArticleRequestStruct); + + const articleView = await UpdateArticleHandler.handle(requester, { + articleId: Number(articleId), + title, + content, + image, + }); + + return res.status(201).send(articleView); + }), +); + +// 게시글 삭제 api +ArticleRouter.delete( + '/:articleId', + AuthN(), + asyncErrorHandler(async (req:Request< {articleId: number}>, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { articleId } = req.params; + + await DeleteArticleHandler.handle(requester, { + articleId: Number(articleId), + }); + + return res.status(204).send(); + }), +); + +// 게시글 목록 조회 api +ArticleRouter.get( + '/', + asyncErrorHandler(async (req:Request< {}, {}, {}, ArticleRouterQuery >, res: Response) => { + const requester = AuthTokenManager.getRequesterFromTokenOrDefault( + req.headers.authorization!, + ); + + const { cursor, limit, orderBy, keyword } = create(req.query, GetArticleListRequestStruct); + + const articleListView = await GetArticleListHandler.handle(requester, { + cursor, + limit, + orderBy, + keyword, + }); + + return res.send(articleListView); + }), +); + +// 게시글 댓글 등록 api +ArticleRouter.post( + '/:articleId/comments', + AuthN(), + asyncErrorHandler(async (req:Request< {articleId: number}, {}, {content: string}>, res:Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { articleId } = req.params; + const { content } = create(req.body, CreateCommentRequestStruct); + + const articleCommentView = await CreateArticleCommentHandler.handle(requester, { + articleId: Number(articleId), + content, + }); + + return res.status(201).send(articleCommentView); + }), +); + +// 게시글 댓글 목록 조회 api +ArticleRouter.get( + '/:articleId/comments', + asyncErrorHandler(async (req: Request<{articleId: number}, {}, {}, ArticleRouterQuery>, res: Response) => { + const { articleId } = req.params; + const { cursor, limit } = create(req.query, GetCommentListRequestStruct); + + const articleCommentListView = await GetArticleCommentListHandler.handle({ + articleId: Number(articleId), + cursor, + take: limit ? Number(limit) : undefined, + }); + + return res.send(articleCommentListView); + }), +); + +// 게시글 좋아요 API +ArticleRouter.post( + '/:articleId/like', + AuthN(), + asyncErrorHandler(async (req: Request < {articleId: TArticle}, {}, {} >, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const articleId = Number(req.params.articleId); + + const articleView = await CreateArticleLikeHandler.handle(requester, { + articleId, + }); + + return res.status(201).send(articleView); + }), +); + +// 게시글 좋아요 취소 API +ArticleRouter.delete( + '/:articleId/like', + AuthN(), + asyncErrorHandler(async (req: Request < {articleId: TArticle}, {}, {} >, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const articleId = Number(req.params.articleId); + + const articleView = await DeleteArticleLikeHandler.handle(requester, { + articleId, + }); + + return res.status(201).send(articleView); + }), +); diff --git a/src/interface/AuthRouter.ts b/src/interface/AuthRouter.ts new file mode 100644 index 00000000..a31bb25e --- /dev/null +++ b/src/interface/AuthRouter.ts @@ -0,0 +1,112 @@ +import express, { Request, Response } from 'express'; +import { create } from 'superstruct'; + +import { asyncErrorHandler } from './utils/asyncErrorHandler'; + +import { SignUpRequestStruct } from './structs/auth/SignUpRequestStruct'; +import { SignInRequestStruct } from './structs/auth/SignInRequestStruct'; +import { RefreshTokenRequestStruct } from './structs/auth/RefreshTokenRequestStruct'; + +import { SignUpLocalUserHandler } from '../application/auth/SignUpLocalUserHandler'; +import { SignInLocalUserHandler } from '../application/auth/SignInLocalUserHandler'; +import { RefreshTokenHandler } from '../application/auth/RefreshTokenHandler'; +import { AuthByGoogleHandler } from '../application/auth/AuthByGoogleHandler'; +import { googleOAuthHelper } from '../infra/GoogleOAuthAdapter'; +import { User } from '@prisma/client'; + +export const AuthRouter = express.Router(); + +type AuthRouterRequest = User & { + passwordConfirmation: string; +} + +// 회원가입 api +AuthRouter.post( + '/signUp', + asyncErrorHandler(async (req : Request<{}, {}, AuthRouterRequest>, res: Response) => { + const { email, nickname, password, passwordConfirmation } = create( + req.body, + SignUpRequestStruct, + ); + + const userView = await SignUpLocalUserHandler.handle({ + email, + nickname, + password, + passwordConfirmation, + }); + + return res.status(201).send(userView); + }), +); + +// 로그인 api +AuthRouter.post( + '/signIn', + asyncErrorHandler(async (req : Request<{}, {}, Pick>, res: Response) => { + const { email, password } = create(req.body, SignInRequestStruct); + + const userView = await SignInLocalUserHandler.handle({ + email, + password, + }); + + return res.send(userView); + }), +); + +// 토큰 갱신 api +AuthRouter.post( + '/refresh-token', + asyncErrorHandler(async (req : Request<{},{},{refreshToken: string;}>, res: Response) => { + const { refreshToken } = create(req.body, RefreshTokenRequestStruct); + + const accessTokenView = await RefreshTokenHandler.handle({ + refreshToken, + }); + + return res.send(accessTokenView); + }), +); + +/** + * 구글 로그인 또는 회원가입을 시작하는 API + * + * 워크 플로: + * 웹 브라우저가 이 API로 GET 메서드와 함께 접속하면, + * 1. 구글 consent screen (구글 로그인 페이지) 주소로 리다이렉트 시켜줍니다. + * 2. 사용자가 구글에서 로그인을 마치면 아래의 `/google/callback`으로 리다이렉트되어 돌아옵니다. + */ +// AuthRouter.get('/google', asyncErrorHandler(async (req : Request, res: Response) => { +// const redirectURI = googleOAuthHelper.generateAuthURI(); +// return res.status(302).redirect(redirectURI); +// })); + +/** + * 구글 로그인 또는 회원가입 + * + * 워크 플로: + * 1. 구글 consent screen에서 로그인을 완료하면 구글에서는 code 값을 쿼리 스트링으로 붙여 이 API로 리다이렉트 시켜준다. + * 2. 사용자의 웹 브라우저는 code 값과 함께 이 API로 GET 요청을 보낸다. + * 3. 백엔드 서버에서는 받은 code를 사용해 구글에서 사용자 데이터를 가져온다. + * 4. 로그인 또는 회원가입 처리를 하고나서 Access Token 및 Refresh Token을 발급해서 쿼리 스트링으로 붙인 다음, + * 5. 클라이언트 페이지로 다시 리다이렉트 시켜준다. + * 6. Access Token과 Refresh Token을 받은 클라이언트 사이트에서는 이것을 사용해 로그인한다. + */ +// AuthRouter.get( +// '/google/callback', +// asyncErrorHandler(async (req : Request<{code: string | undefined}>, res: Response) => { +// const { code } = req.query; + +// const { accessToken, refreshToken } = await AuthByGoogleHandler.handle({ +// code, +// }); + +// const searchParams = new URLSearchParams({ +// at: accessToken, +// rt: refreshToken, +// }); + +// return res.status(302).redirect(`${process.env.CLIENT_REDIRECT_URI}/?${searchParams.toString()}`); +// }), +// ); diff --git a/src/interface/CommentRouter.ts b/src/interface/CommentRouter.ts new file mode 100644 index 00000000..340f162e --- /dev/null +++ b/src/interface/CommentRouter.ts @@ -0,0 +1,50 @@ +import express, { Request, Response } from 'express'; +import { create } from 'superstruct'; + +import { AuthTokenManager } from '../infra/AuthTokenManager'; + +import { asyncErrorHandler } from './utils/asyncErrorHandler'; +import { AuthN } from './utils/AuthN'; + +import { UpdateCommentRequestStruct } from './structs/comment/UpdateCommentRequestStruct'; + +import { UpdateCommentHandler } from '../application/comment/UpdateCommentHandler'; +import { DeleteCommentHandler } from '../application/comment/DeleteCommentHandler'; + +export const CommentRouter = express.Router(); + +// 댓글 수정 api +CommentRouter.patch( + '/:commentId', + AuthN(), + asyncErrorHandler(async (req: Request<{commentId: string;}, {}, {content: string;}>, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { commentId } = req.params; + const { content } = create(req.body, UpdateCommentRequestStruct); + + const commentView = await UpdateCommentHandler.handle(requester, { + commentId: Number(commentId), + content, + }); + + return res.send(commentView); + }), +); + +// 댓글 삭제 api +CommentRouter.delete( + '/:commentId', + AuthN(), + asyncErrorHandler(async (req: Request<{commentId: string;}>, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { commentId } = req.params; + + await DeleteCommentHandler.handle(requester, { + commentId: Number(commentId), + }); + + return res.status(204).send(); + }), +); diff --git a/src/interface/ImageRouter.ts b/src/interface/ImageRouter.ts new file mode 100644 index 00000000..232e14f3 --- /dev/null +++ b/src/interface/ImageRouter.ts @@ -0,0 +1,48 @@ +import express, { Request, Response } from 'express'; +import multer from 'multer'; +import path from 'path'; + +import { asyncErrorHandler } from './utils/asyncErrorHandler'; +import { AuthN } from './utils/AuthN'; + +export const ImageRouter = express.Router(); + +type MylterRequestType = Request& { + file: Express.Multer.File; +} + +const imageUpload = multer({ + storage: multer.diskStorage({ + destination: function (req, file, cb) { + cb(null, path.join(path.resolve(), 'public/images/')); + }, + filename: function (req, file, cb) { + cb(null, [Date.now(), file.originalname].join('-')); + }, + }), + + limits: { + fileSize: 5 * 1024 * 1024, + }, + + fileFilter: function (req, file, cb) { + if (['image/png', 'image/jpeg'].includes(file.mimetype) === false) { + return cb(new Error('Only png and jpeg are allowed')); + } + + cb(null, true); + }, +}); + +// 파일 업로드 API +ImageRouter.post( + '/upload', + AuthN(), + imageUpload.single('image'), + asyncErrorHandler(async (req: MylterRequestType, res: Response) => { + const filePath = path.join('static/images/', req.file.filename); + return res.send({ + url: `${process.env.BASE_URL}/${filePath}`, + }); + }), +); diff --git a/src/interface/ProductRouter.ts b/src/interface/ProductRouter.ts new file mode 100644 index 00000000..aa70f5bc --- /dev/null +++ b/src/interface/ProductRouter.ts @@ -0,0 +1,217 @@ +import express, { Request, Response } from 'express'; +import { create } from 'superstruct'; + +import { AuthTokenManager } from '../infra/AuthTokenManager'; + +import { asyncErrorHandler } from './utils/asyncErrorHandler'; +import { AuthN } from './utils/AuthN'; + +import { CreateProductRequestStruct } from './structs/product/CreateProductRequestStruct'; +import { UpdateProductRequestStruct } from './structs/product/UpdateProductRequestStruct'; +import { GetProductListRequestStruct } from './structs/product/GetProductListRequestStruct'; +import { CreateCommentRequestStruct } from './structs/comment/CreateCommentRequestStruct'; +import { GetCommentListRequestStruct } from './structs/comment/GetCommentListRequestStruct'; + +import { CreateProductHandler } from '../application/product/CreateProductHandler'; +import { GetProductHandler } from '../application/product/GetProductHandler'; +import { UpdateProductHandler } from '../application/product/UpdateProductHandler'; +import { DeleteProductHandler } from '../application/product/DeleteProductHandler'; +import { GetProductListHandler } from '../application/product/GetProductListHandler'; +import { CreateProductCommentHandler } from '../application/product/CreateProductCommentHandler'; +import { GetProductCommentListHandler } from '../application/product/GetProductCommentListHandler'; +import { CreateProductLikeHandler } from '../application/product/CreateProductLikeHandler'; +import { DeleteProductLikeHandler } from '../application/product/DeleteProductLikeHandler'; + +export const ProductRouter = express.Router(); + +type TProductRequestBody = { + name: string; + description: string; + price: string; + tags: string; + images: string; +} + +type TProductQuery = { + page: number; + pageSize: number; + orderBy: "recent" | "favorite"; + keyword: string | undefined; +} + +// 상품 등록 api +ProductRouter.post( + '/', + AuthN(), + asyncErrorHandler(async (req: Request<{}, {}, TProductRequestBody>, res: Response) : Promise => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { name, description, price, tags, images } = create( + req.body, + CreateProductRequestStruct, + ); + + const productView = await CreateProductHandler.handle(requester, { + name, + description, + price, + tags, + images, + }); + + res.status(201).send(productView); + }), +); + +// 상품 조회 api +ProductRouter.get( + '/:productId', + asyncErrorHandler(async (req: Request, res: Response) => { + const requester = AuthTokenManager.getRequesterFromTokenOrDefault( + req.headers.authorization!, + ); + + const { productId } = req.params; + + const productView = await GetProductHandler.handle(requester, { + productId: Number(productId), + }); + + return res.send(productView); + }), +); + +// 상품 수정 api +ProductRouter.patch( + '/:productId', + AuthN(), + asyncErrorHandler(async (req: Request, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { productId } = req.params; + const { name, description, price, tags, images } = create( + req.body, + UpdateProductRequestStruct, + ); + + const productView = await UpdateProductHandler.handle(requester, { + productId: Number(productId), + name, + description, + price, + tags, + images, + }); + + return res.send(productView); + }), +); + +// 상품 삭제 api +ProductRouter.delete( + '/:productId', + AuthN(), + asyncErrorHandler(async (req: Request, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { productId } = req.params; + + await DeleteProductHandler.handle(requester, { + productId: Number(productId), + }); + + return res.status(204).send(); + }), +); + + +// 상품 목록 조회 api +ProductRouter.get( + '/', + asyncErrorHandler(async (req: Request<{}, {}, {}, TProductQuery>, res: Response) => { + const requester = AuthTokenManager.getRequesterFromTokenOrDefault( + req.headers.authorization!, + ); + + const { page, pageSize, orderBy, keyword } = create(req.query, GetProductListRequestStruct); + + const productListView = await GetProductListHandler.handle(requester, { + page, + pageSize, + orderBy, + keyword, + }); + + return res.send(productListView); + }), +); + +// 상품 댓글 등록 api +ProductRouter.post( + '/:productId/comments', + AuthN(), + asyncErrorHandler(async (req: Request<{productId: number}>, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { productId } = req.params; + const { content } = create(req.body, CreateCommentRequestStruct); + + const productCommentView = await CreateProductCommentHandler.handle(requester, { + productId: Number(productId), + content, + }); + + return res.status(201).send(productCommentView); + }), +); + +// 상품 댓글 목록 조회 api +ProductRouter.get( + '/:productId/comments', + asyncErrorHandler(async (req: Request, res: Response) => { + const { productId } = req.params; + const { cursor, limit } = create(req.query, GetCommentListRequestStruct); + + const productCommentListView = await GetProductCommentListHandler.handle({ + productId: Number(productId), + cursor, + limit, + }); + + return res.send(productCommentListView); + }), +); + +// 상품 좋아요 API +ProductRouter.post( + '/:productId/favorite', + AuthN(), + asyncErrorHandler(async (req: Request, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const productId = Number(req.params.productId); + + const productView = await CreateProductLikeHandler.handle(requester, { + productId, + }); + + return res.status(201).send(productView); + }), +); + +// 상품 좋아요 취소 API +ProductRouter.delete( + '/:productId/favorite', + AuthN(), + asyncErrorHandler(async (req: Request, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const productId = Number(req.params.productId); + + const productView = await DeleteProductLikeHandler.handle(requester, { + productId, + }); + + return res.status(201).send(productView); + }), +); diff --git a/src/interface/UserRouter.ts b/src/interface/UserRouter.ts new file mode 100644 index 00000000..203bdba1 --- /dev/null +++ b/src/interface/UserRouter.ts @@ -0,0 +1,113 @@ +import express, { Request, Response } from 'express'; +import { create } from 'superstruct'; + +import { AuthTokenManager } from '../infra/AuthTokenManager'; + +import { asyncErrorHandler } from './utils/asyncErrorHandler'; +import { AuthN } from './utils/AuthN'; + +import { UpdateProfileRequestStruct } from './structs/user/UpdateProfileRequestStruct'; +import { UpdatePasswordRequestStruct } from './structs/user/UpdatePasswordRequestStruct'; +import { GetMyProductListRequestStruct } from './structs/user/GetMyProductListRequestStruct'; +import { GetMyFavoritesProductListRequestStruct } from './structs/user/GetMyFavoritesProductListRequestStruct'; +import { GetUserProfileHandler } from '../application/user/GetUserProfileHandler'; +import { UpdateUserProfileHandler } from '../application/user/UpdateUserProfileHandler'; +import { UpdateUserPasswordHandler } from '../application/user/UpdateUserPasswordHandler'; +import { GetUserProductListHandler } from '../application/user/GetUserProductListHandler'; +import { GetUserFavoriteListHandler } from '../application/user/GetUserFavoriteListHandler'; +import { User } from '@prisma/client'; + +export const UserRouter = express.Router(); + +// 내 정보 조회하기 api +UserRouter.get( + '/me', + AuthN(), + asyncErrorHandler(async (req: Request, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const userView = await GetUserProfileHandler.handle(requester); + + return res.send(userView); + }), +); + +// 내 정보 수정하기 api +UserRouter.patch( + '/me', + AuthN(), + asyncErrorHandler(async (req: Request<{}, {}, {image: string | null}>, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { image } = create(req.body, UpdateProfileRequestStruct); + + const userView = await UpdateUserProfileHandler.handle(requester, { + image, + }); + + return res.send(userView); + }), +); + +// 내 패스워드 수정하기 api +UserRouter.patch( + '/me/password', + AuthN(), + asyncErrorHandler(async (req: Request, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { password, passwordConfirmation, currentPassword } = create( + req.body, + UpdatePasswordRequestStruct, + ); + + const userView = await UpdateUserPasswordHandler.handle(requester, { + password, + passwordConfirmation, + currentPassword, + }); + + return res.send(userView); + }), +); + +// 내가 등록한 상품 조회하기 api +UserRouter.get( + '/me/products', + AuthN(), + asyncErrorHandler(async (req: Request, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { page, pageSize, keyword } = create(req.query, GetMyProductListRequestStruct); + + const productListView = await GetUserProductListHandler.handle(requester, { + page, + pageSize, + keyword, + }); + + return res.send(productListView); + }), +); + +// 내가 좋아요한 상품 조회하기 api +UserRouter.get( + '/me/favorites', + AuthN(), + asyncErrorHandler(async (req: Request, res: Response) => { + const requester = AuthTokenManager.getRequesterFromToken(req.headers.authorization); + + const { page, pageSize, keyword } = create( + req.query, + GetMyFavoritesProductListRequestStruct, + ); + + const favoriteListView = await GetUserFavoriteListHandler.handle(requester, { + page, + pageSize, + keyword, + }); + + return res.send(favoriteListView); + }), +); diff --git a/src/interface/readme.md b/src/interface/readme.md new file mode 100644 index 00000000..7c7a7a96 --- /dev/null +++ b/src/interface/readme.md @@ -0,0 +1,52 @@ + +# Router + +라우팅이란 클라이언트가 서버의 특정 엔드포인트에 접근했을 때, 서버가 응답하는 방식을 의미합니다. + +여기서는 각각의 관심사 별로 라우터를 분리하여 구현하였으며, +이러한 방식은 코드의 유지보수를 쉽게 할 수 있도록 도와줍니다. + +**관심사 별로 라우터를 분리한 예시** + +- ArticleRouter +- CommentRouter +- ImageRouter +- ProductRouter + +**어떤 측면에서 유지보수가 좋아지나요?** + +- 각 라우터 하나하나의 라인수가 너무 커지는 것을 방지 +- 한 라우터를 수정 시, 다른 라우터에 영향이 가는 것을 방지 +- 여러 개발자가 동시에 협엽 시, 버전 관리 충돌을 최소화 할 수 있음 +- 각 라우터별로 최적화된 미들웨어를 사용하여, 접근제어나 캐싱전략을 다르게 가져갈 수 있음 + +## 요청 플로우 + +해당 어플리케이션에서는 클라이언트의 요청이 들어왔을 때, 다음과 같이 처리합니다. + +```mermaid +flowchart TD + A[요청이 들어옴] --> B{해당 요청을 처리할 수 있는 라우팅 함수가 있는가?} + B --> |없다면| X[404 Not Found] + B --> |있다면, 해당 라우팅 함수로 이동| C{superstruct 를 통하여 \n 필요 시 페이로드를 전처리하고, \n 우리의 의도에 맞게 보냈는지 판단} + C -->|올바르지 않은 페이로드| D[404 Bad Request] + C --> E(서비스 로직 처리 시도) + E -->|성공| F[2xx 응답 반환] + E -->|관련 자원을 찾을 수 없음| G[404 Not Found] + E -->|그 외의 에러| H[500 Internal Server Error] +``` + +### 유효성을 검사하기 전에, 페이로드 전처리는 왜 필요한가요? + +전처리가 필요한 유효성 검사 케이스는 생각보다 흔합니다. + +예를 들어, + +- 상품 생성 요청 시, ProductName 에는 `앞뒤로 공백이 없어야 하며` 제목이 2글자 이상 되게끔 해주세요. +- 상품 목록 조회 시, cursor 값이 문자열로 오고 있어요, `미리미리 숫자로 바꿔서 사용` 하고 싶어요. +- 상품 목록 조회 시, cursor 값이 명시적으로 주어지지 않았다면 `기본값으로 0을 사용` 하게끔 해주세요. + +이러한 전처리는 라우팅 함수 내에서 해도 결과적으로는 문제가 없습니다. + +하지만 라우팅 함수가 처리로직에만 집중할 수 있도록, +처리로직 이외의 동작은 다른 코드에 위임하는 것이 좋습니다. diff --git a/src/interface/structs/article/CreateArticleRequestStruct.ts b/src/interface/structs/article/CreateArticleRequestStruct.ts new file mode 100644 index 00000000..63dc90ba --- /dev/null +++ b/src/interface/structs/article/CreateArticleRequestStruct.ts @@ -0,0 +1,20 @@ +import { coerce, nullable, object, nonempty, string, defaulted } from 'superstruct'; + +export const CreateArticleRequestStruct = object({ + /** + * [데이터 전처리 - 데이터 변환] + * + * coerce 메서드는 데이터 변환을 같이 수행합니다. + * + * 파라미터로 (struct, condition, transformer) 를 받으며, 아래와 같이 동작합니다. + * 1. condition 에 일치한 경우 transformer 를 실행하여 값을 변환합니다. + * 2. 변환된 값이 struct 에 맞는지 검사합니다. + * + * 아래 코드는 title 의 앞뒤 공백을 제거합니다. + * + * @see https://docs.superstructjs.org/api-reference/coercions#custom-coercions + */ + title: coerce(nonempty(string()), string(), (value) => value.trim()), // 또는 trimmed() 를 사용하여 구현할 수 있습니다. + content: nonempty(string()), + image: nullable(string()), +}); diff --git a/src/interface/structs/article/GetArticleListRequestStruct.ts b/src/interface/structs/article/GetArticleListRequestStruct.ts new file mode 100644 index 00000000..8fa6876c --- /dev/null +++ b/src/interface/structs/article/GetArticleListRequestStruct.ts @@ -0,0 +1,40 @@ +import { + coerce, + optional, + object, + integer, + string, + min, + max, + enums, + nonempty, + defaulted, +} from 'superstruct'; + +export const GetArticleListRequestStruct = object({ + /** + * [데이터 전처리 - 기본값 설정] + * + * defaulted 메서드는 기본값을 설정하기 위해 사용됩니다. + * + * 파라미터로는 (struct, defaultValue) 를 받으며, 아래와 같이 동작합니다. + * 1. 값이 undefined 인 경우, defaultValue 를 반환합니다. + * 2. 값이 undefined 가 아닌 경우, struct 에 맞는지 검사합니다. 추가적인 변환이 수행될 수 있습니다. + * + * 아래 코드는 cursor 가 undefined 인 경우, 0 을 반환합니다. + * + * @see https://docs.superstructjs.org/api-reference/coercions#defaulted + */ + cursor: defaulted( + coerce(min(integer(), 0), string(), (value) => Number.parseInt(value, 10)), + 0 + ), + limit: defaulted( + coerce(max(min(integer(), 1), 10), string(), (value) => + Number.parseInt(value, 10) + ), + 10 + ), + orderBy: defaulted(enums(['recent', 'favorite']), 'recent'), + keyword: optional(nonempty(string())), +}); diff --git a/src/interface/structs/article/UpdateArticleRequestStruct.ts b/src/interface/structs/article/UpdateArticleRequestStruct.ts new file mode 100644 index 00000000..a0683c4b --- /dev/null +++ b/src/interface/structs/article/UpdateArticleRequestStruct.ts @@ -0,0 +1,5 @@ +import { partial } from 'superstruct'; + +import { CreateArticleRequestStruct } from './CreateArticleRequestStruct'; + +export const UpdateArticleRequestStruct = partial(CreateArticleRequestStruct); diff --git a/src/interface/structs/auth/RefreshTokenRequestStruct.ts b/src/interface/structs/auth/RefreshTokenRequestStruct.ts new file mode 100644 index 00000000..07884245 --- /dev/null +++ b/src/interface/structs/auth/RefreshTokenRequestStruct.ts @@ -0,0 +1,5 @@ +import { object, nonempty, string } from 'superstruct'; + +export const RefreshTokenRequestStruct = object({ + refreshToken: nonempty(string()), +}); diff --git a/src/interface/structs/auth/SignInRequestStruct.ts b/src/interface/structs/auth/SignInRequestStruct.ts new file mode 100644 index 00000000..8a5553ba --- /dev/null +++ b/src/interface/structs/auth/SignInRequestStruct.ts @@ -0,0 +1,7 @@ +import { object, nonempty, string, define } from 'superstruct'; +import isEmail from 'is-email'; + +export const SignInRequestStruct = object({ + email: define('Email', (value: unknown) => typeof value === 'string' && isEmail(value)), + password: nonempty(string()), +}); diff --git a/src/interface/structs/auth/SignUpRequestStruct.ts b/src/interface/structs/auth/SignUpRequestStruct.ts new file mode 100644 index 00000000..2b19e2c0 --- /dev/null +++ b/src/interface/structs/auth/SignUpRequestStruct.ts @@ -0,0 +1,9 @@ +import { coerce, object, nonempty, string, define, boolean } from 'superstruct'; +import isEmail from 'is-email'; + +export const SignUpRequestStruct = object({ + email: define('Email', (value: unknown) => typeof value === 'string' && isEmail(value)), + nickname: coerce(nonempty(string()), string(), (value) => value.trim()), + password: nonempty(string()), + passwordConfirmation: nonempty(string()), +}); diff --git a/src/interface/structs/comment/CreateCommentRequestStruct.ts b/src/interface/structs/comment/CreateCommentRequestStruct.ts new file mode 100644 index 00000000..2a93d668 --- /dev/null +++ b/src/interface/structs/comment/CreateCommentRequestStruct.ts @@ -0,0 +1,5 @@ +import { nonempty, object, string } from 'superstruct'; + +export const CreateCommentRequestStruct = object({ + content: nonempty(string()), +}); diff --git a/src/interface/structs/comment/GetCommentListRequestStruct.ts b/src/interface/structs/comment/GetCommentListRequestStruct.ts new file mode 100644 index 00000000..53e3d288 --- /dev/null +++ b/src/interface/structs/comment/GetCommentListRequestStruct.ts @@ -0,0 +1,12 @@ +import { coerce, defaulted, object, string, min, max, integer } from 'superstruct'; + +export const GetCommentListRequestStruct = object({ + cursor: defaulted( + coerce(min(integer(), 0), string(), (value) => Number.parseInt(value, 10)), + 0, + ), + limit: defaulted( + coerce(max(min(integer(), 1), 10), string(), (value) => Number.parseInt(value, 10)), + 10, + ), +}); diff --git a/src/interface/structs/comment/UpdateCommentRequestStruct.ts b/src/interface/structs/comment/UpdateCommentRequestStruct.ts new file mode 100644 index 00000000..921526ab --- /dev/null +++ b/src/interface/structs/comment/UpdateCommentRequestStruct.ts @@ -0,0 +1,5 @@ +import { partial } from 'superstruct'; + +import { CreateCommentRequestStruct } from './CreateCommentRequestStruct'; + +export const UpdateCommentRequestStruct = partial(CreateCommentRequestStruct); diff --git a/src/interface/structs/product/CreateProductRequestStruct.ts b/src/interface/structs/product/CreateProductRequestStruct.ts new file mode 100644 index 00000000..1d1bac63 --- /dev/null +++ b/src/interface/structs/product/CreateProductRequestStruct.ts @@ -0,0 +1,9 @@ +import { coerce, object, nonempty, string, min, integer, array } from 'superstruct'; + +export const CreateProductRequestStruct = object({ + name: coerce(nonempty(string()), string(), (value) => value.trim()), + description: nonempty(string()), + price: min(integer(), 0), + tags: array(nonempty(string())), + images: array(nonempty(string())), +}); diff --git a/src/interface/structs/product/GetProductListRequestStruct.ts b/src/interface/structs/product/GetProductListRequestStruct.ts new file mode 100644 index 00000000..0984662e --- /dev/null +++ b/src/interface/structs/product/GetProductListRequestStruct.ts @@ -0,0 +1,27 @@ +import { + coerce, + optional, + object, + integer, + string, + min, + max, + enums, + nonempty, + defaulted, +} from 'superstruct'; + +export const GetProductListRequestStruct = object({ + page: defaulted( + coerce(min(integer(), 1), string(), (value) => Number.parseInt(value, 10)), + 1 + ), + pageSize: defaulted( + coerce(max(min(integer(), 1), 12), string(), (value) => + Number.parseInt(value, 10) + ), + 10 + ), + orderBy: defaulted(enums(['recent', 'favorite']), 'recent'), + keyword: optional(nonempty(string())), +}); diff --git a/src/interface/structs/product/UpdateProductRequestStruct.ts b/src/interface/structs/product/UpdateProductRequestStruct.ts new file mode 100644 index 00000000..49ae3f97 --- /dev/null +++ b/src/interface/structs/product/UpdateProductRequestStruct.ts @@ -0,0 +1,5 @@ +import { partial } from 'superstruct'; + +import { CreateProductRequestStruct } from './CreateProductRequestStruct'; + +export const UpdateProductRequestStruct = partial(CreateProductRequestStruct); diff --git a/src/interface/structs/user/GetMyFavoritesProductListRequestStruct.ts b/src/interface/structs/user/GetMyFavoritesProductListRequestStruct.ts new file mode 100644 index 00000000..a879e7ca --- /dev/null +++ b/src/interface/structs/user/GetMyFavoritesProductListRequestStruct.ts @@ -0,0 +1,23 @@ +import { + coerce, + optional, + object, + integer, + string, + min, + max, + nonempty, + defaulted, +} from 'superstruct'; + +export const GetMyFavoritesProductListRequestStruct = object({ + page: defaulted( + coerce(min(integer(), 0), string(), (value) => Number.parseInt(value, 10)), + 1, + ), + pageSize: defaulted( + coerce(max(min(integer(), 1), 10), string(), (value) => Number.parseInt(value, 10)), + 10, + ), + keyword: optional(nonempty(string())), +}); diff --git a/src/interface/structs/user/GetMyProductListRequestStruct.ts b/src/interface/structs/user/GetMyProductListRequestStruct.ts new file mode 100644 index 00000000..46d24977 --- /dev/null +++ b/src/interface/structs/user/GetMyProductListRequestStruct.ts @@ -0,0 +1,24 @@ +import { + coerce, + optional, + object, + integer, + string, + min, + max, + enums, + nonempty, + defaulted, +} from 'superstruct'; + +export const GetMyProductListRequestStruct = object({ + page: defaulted( + coerce(min(integer(), 0), string(), (value) => Number.parseInt(value, 10)), + 1, + ), + pageSize: defaulted( + coerce(max(min(integer(), 1), 10), string(), (value) => Number.parseInt(value, 10)), + 10, + ), + keyword: optional(nonempty(string())), +}); diff --git a/src/interface/structs/user/UpdatePasswordRequestStruct.ts b/src/interface/structs/user/UpdatePasswordRequestStruct.ts new file mode 100644 index 00000000..6b4cd276 --- /dev/null +++ b/src/interface/structs/user/UpdatePasswordRequestStruct.ts @@ -0,0 +1,7 @@ +import { nonempty, object, string } from 'superstruct'; + +export const UpdatePasswordRequestStruct = object({ + password: nonempty(string()), + passwordConfirmation: nonempty(string()), + currentPassword: nonempty(string()), +}); diff --git a/src/interface/structs/user/UpdateProfileRequestStruct.ts b/src/interface/structs/user/UpdateProfileRequestStruct.ts new file mode 100644 index 00000000..e9ad4fa5 --- /dev/null +++ b/src/interface/structs/user/UpdateProfileRequestStruct.ts @@ -0,0 +1,5 @@ +import { nullable, object, string } from 'superstruct'; + +export const UpdateProfileRequestStruct = object({ + image: nullable(string()), +}); diff --git a/src/interface/utils/AuthN.ts b/src/interface/utils/AuthN.ts new file mode 100644 index 00000000..55ee3889 --- /dev/null +++ b/src/interface/utils/AuthN.ts @@ -0,0 +1,31 @@ +import { NextFunction, Request, Response } from 'express'; +import { AuthTokenManager } from '../../infra/AuthTokenManager'; + +/** + * 인증 미들웨어 + * + * HTTP 메시지에서 authorization 헤더로 전달된 JWT 토큰을 검증합니다. + * 아래 케이스의 경우 401 Unauthorized 응답을 반환합니다. + * + * - JWT 토큰이 전달되지 않은 경우 + * - JWT 토큰이 유효하지 않은 경우 (ex 시크릿 키가 일치하지 않는 경우) + * - JWT 토큰이 만료된 경우 + */ +export function AuthN() { + return async function (req:Request, res:Response, next:NextFunction): Promise { + const authHeader = req.headers.authorization; + const jwtToken = req?.headers?.authorization?.split(' ')[1]; // "bearer JWT_TOKEN" 형태로 전달받음 + + + if (!jwtToken || AuthTokenManager.isValidAccessToken(jwtToken) === false) { + res.status(401).send({ + name: 'Unauthorized', + message: 'Invalid JWT token', + }); + return; // 반드시 return으로 함수 종료! + } + + // 토큰이 유효하면 다음 미들웨어로 진행 + next(); + }; +} diff --git a/src/interface/utils/asyncErrorHandler.ts b/src/interface/utils/asyncErrorHandler.ts new file mode 100644 index 00000000..7e2c6de9 --- /dev/null +++ b/src/interface/utils/asyncErrorHandler.ts @@ -0,0 +1,50 @@ +import superstruct from 'superstruct'; + +import { HttpException } from '../../exceptions/HttpException'; +import { BadRequestException } from '../../exceptions/BadRequestException'; +import { InternalServerErrorException } from '../../exceptions/InternalServerErrorException'; +import { Request, Response } from 'express'; + +export function asyncErrorHandler(handler: any) { + return async function (req: Request, res: Response) { + try { + await handler(req, res); + } catch (e) { + // 에러처리 로직을 일관화하기 위해, HttpException 으로 변환합니다. + const httpException = mapToHttpException(e); + handleHttpException(httpException, res); + } + }; +} + +function handleHttpException(httpError: any, res: Response) { + res.status(httpError.status).send({ + name: httpError.name, + message: httpError.message, + }); +} + +/** + * [에러로직 일관화를 위한, Exception 변환 메서드] + * + * 해당 메서드는 항상 HttpException 을 반환합니다. + * + * 동작: + * 1. HttpException 이라면 그대로 반환합니다. + * 2. Known Error 라면, 해당 에러에 맞는 HttpException 으로 변환합니다. + * 3. Unknown Error 라면, InternalServerErrorException 으로 변환합니다. + */ +function mapToHttpException(e:any) { + if (e instanceof HttpException) { + return e; + } + + // Known Error + if (e instanceof superstruct.StructError) { + return new BadRequestException('Validation Failed', e.message); + } + + // 마지막까지 처리되지 않았다면, Unknown Error 입니다. + // InternalServerErrorException 으로 변환합니다. + return new InternalServerErrorException('Internal Server Error', e.message); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 00000000..f5531524 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,59 @@ +import path from 'path'; +import dotenv from 'dotenv'; +dotenv.config({ path: path.join(path.resolve(), '.env') }); + +import express, { NextFunction, Request, Response } from 'express'; +import cors from 'cors'; +import morgan from 'morgan'; +import swaggerUi from 'swagger-ui-express'; +import yaml from 'yaml'; +import fs from 'fs'; + +import { AuthRouter } from './interface/AuthRouter'; +import { ArticleRouter } from './interface/ArticleRouter'; +import { ProductRouter } from './interface/ProductRouter'; +import { CommentRouter } from './interface/CommentRouter'; +import { ImageRouter } from './interface/ImageRouter'; +import { UserRouter } from './interface/UserRouter'; + +const app = express(); +app.use(cors()); +app.use(express.json()); +app.use(morgan('dev')); + +app.use( + '/api-docs', + swaggerUi.serve, + swaggerUi.setup( + yaml.parse(fs.readFileSync(path.join(path.resolve(), 'openapi.yaml'), 'utf-8')), + ), +); + +/** + * 현재 디렉터리의 public 폴더를 외부 브라우저에서 접근할 수 있도록 설정합니다. + * static 이라는 이름으로 접근할 수 있습니다. + * + * @example http://localhost:3000/static/images/sample-image.jpg + */ +app.use('/static', express.static(path.join(path.resolve(), 'public/'))); + +app.use('/auth', AuthRouter); +app.use('/articles', ArticleRouter); +app.use('/products', ProductRouter); +app.use('/comments', CommentRouter); +app.use('/images', ImageRouter); +app.use('/users', UserRouter); +app.use(( + err: any, + req: Request, + res:Response, + next:NextFunction + ) => { + console.error(err.stack); + res.status(500).send({ + message: '예기치 못한 오류가 발생했습니다.', + }); +}); + +const port = process.env.HTTP_PORT ?? 4000; +app.listen(port, () => console.log(`Server started on port: ${port}`)); diff --git a/src/readme.md b/src/readme.md new file mode 100644 index 00000000..51001d08 --- /dev/null +++ b/src/readme.md @@ -0,0 +1,73 @@ + +# Software Architecture + +해당 예제에서는 DDD(`Domain Driven Development`) 의 개념을 일부 차용하여 작성되었습니다. + +DDD 에서는 소프트웨어의 레이어를 다음과 같이 구분합니다. + +- Infra Layer (`src/infra`) +- Interface Layer (`src/interface`) +- Application Layer (`src/application`) +- Domain Layer (`src/domain`) + +## Domain Layer + +DB에 영속화되며 소프트웨어에서 주로 다루고 있는 관심사를 도메인이라고 합니다. + +판다 마켓에서는 다음과 같이 5개의 도메인을 다루고 있습니다. + +- Article +- Comment +- Like +- Product +- User + +각 도매인에서는 고유규칙을 가지고 있을 수 있으며, 이것을 `도메인 규칙` 이라고 합니다. + +아직 판다마켓에서는 도메인 규칙이 없지만, 도메인 규칙으로 다음 예시들을 들 수 있습니다. + +- 구글로 가입한 사용자는 패스워드로 로그인할 수 없다. +- 아카이브 상태의 게시글은 제목을 수정할 수 없다. +- ... + + +## Application Layer + +도메인 객체과 그 외의 의존성을 조합하여 기능을 구현하는 레이어입니다. + +예를 들어 `구글 로그인` 을 구현하기 위해서는 아래의 3개 준비물이 필요합니다. + +- User (도메인 객체) +- GoogleOAuthAdapter (의존성) +- Prisma Client (의존성) + +마찬가지로 `게시글에 좋아요` 를 구현하기 위해서는 아래의 3개 준비물이 필요합니다. + +- Article (도메인 객체) +- Like (도메인 객체) +- Prisma Client (의존성) + + +## Interface Layer + +사용자와의 상호작용을 주요 관심사로 다루는 레이어입니다. + +아래 항목들을 관리합니다. + +- API 인터페이스 정의 + - 요청 페이로드 형식 + - 요청 페이로드에 대한 유효성 검사 + - 응답 형식 +- 인증 +- 추가적인 인가 + + +## Infra Layer + +외부 의존성을 주요 관심사로 다루는 레이어입니다. + +- 사용하고 있는 DB 및 클라이언트 +- AuthToken 발급 방식 +- 유저 패스워드 암호화 방식 +- 구글 OAuth API 인터페이스 + diff --git a/src/types/article.ts b/src/types/article.ts new file mode 100644 index 00000000..6f0db9aa --- /dev/null +++ b/src/types/article.ts @@ -0,0 +1,28 @@ +import { TLikeParam } from "./like"; + +export type TArticleUser = { + userId: number; +} + +export type TArticle = { + articleId: number; + title: string; + content: string; + image: string | null; + cursor?: number | undefined; + limit?: number | undefined; + take?: number | undefined; + orderBy? : 'favorite' | 'recent'; + keyword? : string | undefined; +} + +export type TArticleParam = { + id: number; + writerId: number; + title: string; + content: string; + image?: string | null; + createdAt: Date; + updatedAt: Date; + likes?: TLikeParam[] | null; +} \ No newline at end of file diff --git a/src/types/comment.ts b/src/types/comment.ts new file mode 100644 index 00000000..50758053 --- /dev/null +++ b/src/types/comment.ts @@ -0,0 +1,9 @@ +export type TCommentParam = { + id: number; + writerId: number; + articleId?: number | null; + productId?: number | null; + content: string; + createdAt: Date; + updatedAt: Date; +} \ No newline at end of file diff --git a/src/types/error.ts b/src/types/error.ts new file mode 100644 index 00000000..a11410b8 --- /dev/null +++ b/src/types/error.ts @@ -0,0 +1,48 @@ +// src/types/errors.ts +export class AppError extends Error { + code?: number; // 선택적 속성으로 변경 + data?: any; // 에러핸들러에서 사용하는 data 속성도 추가 + + constructor(message: string, code?: number, data?: any) { + super(message); + this.code = code; + this.data = data; + this.name = "AppError"; + } +} + +// 자주 사용하는 에러들을 위한 편의 클래스들 +export class ValidationError extends AppError { + constructor(message: string, data?: any) { + super(message, 422, data); // 422는 기본값 + this.name = "ValidationError"; + } +} + +export class AuthenticationError extends AppError { + constructor(message: string, data?: any) { + super(message, 401, data); // 401은 기본값 + this.name = "AuthenticationError"; + } +} + +export class ServerError extends AppError { + constructor(message: string, data?: any) { + super(message, 500, data); // 500은 기본값 + this.name = "ServerError"; + } +} + +export class NotFoundError extends AppError { + constructor(message: string, data?: any) { + super(message, 404, data); // 404은 기본값 + this.name = "NotFoundError"; + } +} + +export class ForbiddenError extends AppError { + constructor(message: string, data?: any) { + super(message, 403, data); // 403은 기본값 + this.name = "ForbiddenError"; + } +} \ No newline at end of file diff --git a/src/types/express.d.ts b/src/types/express.d.ts new file mode 100644 index 00000000..8474e528 --- /dev/null +++ b/src/types/express.d.ts @@ -0,0 +1,9 @@ +import { Express } from "express"; + +declare global { + namespace Express{ + interface Request { + // 속성 추가 영역 + } + } +} \ No newline at end of file diff --git a/src/types/like.ts b/src/types/like.ts new file mode 100644 index 00000000..28fd27de --- /dev/null +++ b/src/types/like.ts @@ -0,0 +1,7 @@ +export type TLikeParam = { + id: number; + userId: number; + productId?: number; + articleId?: number; + createdAt?: Date; + } \ No newline at end of file diff --git a/src/types/product.ts b/src/types/product.ts new file mode 100644 index 00000000..e838452c --- /dev/null +++ b/src/types/product.ts @@ -0,0 +1,29 @@ +import { Prisma } from "@prisma/client"; +import { TLikeParam } from "./like"; + +export type TProductUser = { + userId: number; +} + +export type TProduct = { + productId: number; + content: string; + name: string; + description: string; + price: number; + tags?: string[] | undefined; + images : string[] | Prisma.ProductCreateimagesInput | undefined; +} + +export type TProductParam = { + name: string; + id: number; + createdAt: Date; + updatedAt: Date; + description: string; + price: number; + tags: string[]; + images: string[]; + ownerId: number; + likes?: TLikeParam[]; +} \ No newline at end of file diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 00000000..931c4fbe --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,9 @@ +export type TUserParam = { + id: number; + email: string; + password: string; + nickname: string; + image?: string | null; + createdAt: Date; + updatedAt: Date; +} \ No newline at end of file diff --git a/swagger/components.js b/swagger/components.js new file mode 100644 index 00000000..e69de29b diff --git a/swagger/index.js b/swagger/index.js new file mode 100644 index 00000000..e69de29b diff --git a/swagger/info.js b/swagger/info.js new file mode 100644 index 00000000..e69de29b diff --git a/swagger/paths.js b/swagger/paths.js new file mode 100644 index 00000000..e69de29b diff --git a/swagger/swagger.js b/swagger/swagger.js new file mode 100644 index 00000000..2342dbc3 --- /dev/null +++ b/swagger/swagger.js @@ -0,0 +1,21 @@ +const swaggerJSDoc = require("swagger-jsdoc"); +const swaggerUi = require("swagger-ui-express"); + +const option = { + definition: { + openai: "3.0.0", + info: { + title: "나의 API", + versiton: "1.0.0", + }, + }, + apis: ["./routes/*.js"], +}; + +const specs = swaggerJSDoc(options); + +module.exports = { swaggerUi, specs }; + +router.get("/user", getUsers); + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..67c9fd54 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,117 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig to read more about this file */ + + /* Projects */ + // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ + // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ + // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ + // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ + // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ + // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ + + /* Language and Environment */ + "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ + // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ + // "jsx": "preserve", /* Specify what JSX code is generated. */ + // "libReplacement": true, /* Enable lib replacement. */ + // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ + // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ + // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ + // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ + // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ + // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ + // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ + // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ + // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ + + /* Modules */ + "module": "commonjs", /* Specify what module code is generated. */ + "rootDir": "./src", /* Specify the root folder within your source files. */ + // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ + "baseUrl": ".", /* Specify the base directory to resolve non-relative module names. */ + "paths": { + "@/*" : ["src/*"] + }, /* Specify a set of entries that re-map imports to additional lookup locations. */ + // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ + // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ + // "types": [], /* Specify type package names to be included without being referenced in a source file. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ + // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ + // "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */ + // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ + // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ + // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ + // "noUncheckedSideEffectImports": true, /* Check side effect imports. */ + // "resolveJsonModule": true, /* Enable importing .json files. */ + // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ + // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ + + /* JavaScript Support */ + "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ + // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ + // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ + + /* Emit */ + // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ + // "declarationMap": true, /* Create sourcemaps for d.ts files. */ + // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ + "sourceMap": true, /* Create source map files for emitted JavaScript files. */ + // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ + // "noEmit": true, /* Disable emitting files from a compilation. */ + // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ + "outDir": "./dist", /* Specify an output folder for all emitted files. */ + // "removeComments": true, /* Disable emitting comments. */ + // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ + // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ + // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ + // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ + // "newLine": "crlf", /* Set the newline character for emitting files. */ + // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ + // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ + // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ + // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ + // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ + + /* Interop Constraints */ + // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ + // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */ + // "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */ + // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ + "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ + // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ + "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ + + /* Type Checking */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ + // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ + // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ + // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */ + // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ + // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ + // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ + // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ + // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ + // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ + // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ + // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ + // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ + // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ + // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ + // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ + // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ + + /* Completeness */ + // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */ + }, + "include": ["src/**/*"], // 컴파일 대상 폴더 + "exclude": ["node_modules"] // 컴파일 제외 폴더 +}