first commit
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.github
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
coverage
|
||||||
|
.nyc_output
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
NODE_ENV=production
|
||||||
|
PORT=3003
|
||||||
|
|
||||||
|
# Nest build
|
||||||
|
BUILD_COMMAND=npm run build:prod
|
||||||
|
|
||||||
|
# Database
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_DB=myapp_prod
|
||||||
|
POSTGRES_USER=myapp_prod
|
||||||
|
POSTGRES_PASSWORD=CHANGE_ME
|
||||||
|
DATABASE_URL=postgresql://myapp_prod:CHANGE_ME@postgres:5432/myapp_prod
|
||||||
|
|
||||||
|
REDIS_HOST=redis
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=CHANGE_ME
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Server Environment - Test
|
||||||
|
NODE_ENV=test
|
||||||
|
PORT=3001
|
||||||
|
HOST=127.0.0.1
|
||||||
|
LOG_LEVEL=silent
|
||||||
|
|
||||||
|
# Database (PostgreSQL Test Container/DB)
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/supporthub_test_db?schema=public
|
||||||
|
|
||||||
|
# Redis Test Container/DB
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# Authentication Test Keys
|
||||||
|
JWT_SECRET=super-secret-test-jwt-key-min-32-characters
|
||||||
|
|
||||||
|
# AWS S3 Test Storage
|
||||||
|
AWS_REGION=us-east-1
|
||||||
|
AWS_S3_BUCKET=supporthub-test-bucket
|
||||||
|
AWS_ACCESS_KEY_ID=test-aws-access-key-id
|
||||||
|
AWS_SECRET_ACCESS_KEY=test-aws-secret-access-key
|
||||||
|
AWS_S3_ENDPOINT=
|
||||||
|
|
||||||
|
# Security & CORS Test
|
||||||
|
CORS_ORIGINS=*
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Explicit binary files
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.pdf binary
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing & Coverage
|
||||||
|
coverage/
|
||||||
|
*.lcov
|
||||||
|
.vitest/
|
||||||
|
|
||||||
|
# Build Output
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Environment Variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development
|
||||||
|
.env.production
|
||||||
|
.env.test
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pino-*.log
|
||||||
|
|
||||||
|
# Editor & System Files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
.idea/
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker Persistent Volumes Data (Local)
|
||||||
|
docker/postgres/data/
|
||||||
|
docker/redis/data/
|
||||||
|
docker/minio/data/
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
npx lint-staged
|
||||||
|
npx tsx scripts/check-architecture.ts
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Contributing to SupportHub API
|
||||||
|
|
||||||
|
Thank you for contributing to SupportHub API. Please review the following guidelines before submitting changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branch Naming Convention
|
||||||
|
|
||||||
|
- `feature/<short-description>`: New domain feature or infrastructure addition
|
||||||
|
- `bugfix/<short-description>`: Bug fix
|
||||||
|
- `refactor/<short-description>`: Code restructuring without functional changes
|
||||||
|
- `chore/<short-description>`: Tooling, dependency, or documentation updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commit Message Format
|
||||||
|
|
||||||
|
Use Conventional Commits:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<type>(<scope>): <short summary>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
```
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `feat(ticketing): add ticket priority calculator strategy`
|
||||||
|
- `fix(queue): resolve BullMQ connection leak on shutdown`
|
||||||
|
- `chore(deps): update fastify to 4.26.2`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Rules
|
||||||
|
|
||||||
|
1. **Self-Contained Modules**: Every domain feature belongs in `src/modules/<group>/<module>`.
|
||||||
|
2. **Public Boundary**: Expose public domain interfaces strictly via `index.ts`. Deep cross-module imports are prohibited.
|
||||||
|
3. **Layer Isolation**:
|
||||||
|
- `HTTP Route / Controller` -> Calls `Service`
|
||||||
|
- `Service` -> Calls `Repository` / `Engine`
|
||||||
|
- `Repository` -> Calls `PrismaClient` / `Database`
|
||||||
|
- Controllers **MUST NOT** directly query Prisma.
|
||||||
|
4. **Architecture Check**: Run `npm run architecture:check` before committing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Quality & Verification Gates
|
||||||
|
|
||||||
|
Before submitting a Pull Request, all of the following local commands must pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run architecture:check
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
npm run test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull Request Guidelines
|
||||||
|
|
||||||
|
1. Create a PR against `main`.
|
||||||
|
2. Provide a clear description of changes and design decisions.
|
||||||
|
3. Ensure CI pipeline checks pass completely.
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
|
FROM base AS dependencies
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM dependencies AS builder
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG BUILD_COMMAND="npm run build"
|
||||||
|
|
||||||
|
RUN sh -c "$BUILD_COMMAND"
|
||||||
|
|
||||||
|
FROM base AS production-dependencies
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm ci --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
|
FROM base AS runner
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
COPY --from=builder --chown=node:node /app/dist ./dist
|
||||||
|
|
||||||
|
COPY --chown=node:node package*.json ./
|
||||||
|
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Development
|
||||||
|
docker compose --env-file .env.development -f docker-compose.development.yml up -d --build
|
||||||
|
|
||||||
|
# Test
|
||||||
|
docker compose --env-file .env.test -f docker-compose.test.yml up --build
|
||||||
|
|
||||||
|
# Production
|
||||||
|
docker compose --env-file .env.prod -f docker-compose.prod.yml up --build -d
|
||||||
|
|
||||||
|
# Stop
|
||||||
|
docker compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_COMMAND: ${BUILD_COMMAND}
|
||||||
|
|
||||||
|
container_name: support-development
|
||||||
|
|
||||||
|
env_file:
|
||||||
|
- .env.development
|
||||||
|
|
||||||
|
environment:
|
||||||
|
NODE_ENV: ${NODE_ENV}
|
||||||
|
PORT: ${PORT}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
REDIS_HOST: ${REDIS_HOST}
|
||||||
|
REDIS_PORT: ${REDIS_PORT}
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "${PORT}:${PORT}"
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
|
||||||
|
container_name: postgres-development
|
||||||
|
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- postgres_development_data:/var/lib/postgresql
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
|
||||||
|
container_name: redis-development
|
||||||
|
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --requirepass
|
||||||
|
- ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- redis_development_data:/data
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"redis-cli",
|
||||||
|
"-a",
|
||||||
|
"${REDIS_PASSWORD}",
|
||||||
|
"ping"
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_development_data:
|
||||||
|
redis_development_data:
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_COMMAND: ${BUILD_COMMAND}
|
||||||
|
|
||||||
|
container_name: support-production
|
||||||
|
|
||||||
|
env_file:
|
||||||
|
- .env.prod
|
||||||
|
|
||||||
|
environment:
|
||||||
|
NODE_ENV: ${NODE_ENV}
|
||||||
|
PORT: ${PORT}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
REDIS_HOST: ${REDIS_HOST}
|
||||||
|
REDIS_PORT: ${REDIS_PORT}
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "${PORT}:${PORT}"
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
|
||||||
|
container_name: postgres-production
|
||||||
|
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- postgres_production_data:/var/lib/postgresql
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
|
||||||
|
container_name: redis-production
|
||||||
|
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --requirepass
|
||||||
|
- ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- redis_production_data:/data
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"redis-cli",
|
||||||
|
"-a",
|
||||||
|
"${REDIS_PASSWORD}",
|
||||||
|
"ping"
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_production_data:
|
||||||
|
redis_production_data:
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_COMMAND: ${BUILD_COMMAND}
|
||||||
|
|
||||||
|
container_name: support-test
|
||||||
|
|
||||||
|
env_file:
|
||||||
|
- .env.test
|
||||||
|
|
||||||
|
environment:
|
||||||
|
NODE_ENV: ${NODE_ENV}
|
||||||
|
PORT: ${PORT}
|
||||||
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
|
REDIS_HOST: ${REDIS_HOST}
|
||||||
|
REDIS_PORT: ${REDIS_PORT}
|
||||||
|
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "${PORT}:${PORT}"
|
||||||
|
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18-alpine
|
||||||
|
|
||||||
|
container_name: postgres-test
|
||||||
|
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- postgres_test_data:/var/lib/postgresql
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
|
||||||
|
container_name: redis-test
|
||||||
|
|
||||||
|
command:
|
||||||
|
- redis-server
|
||||||
|
- --requirepass
|
||||||
|
- ${REDIS_PASSWORD}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- redis_test_data:/data
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"redis-cli",
|
||||||
|
"-a",
|
||||||
|
"${REDIS_PASSWORD}",
|
||||||
|
"ping"
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_test_data:
|
||||||
|
redis_test_data:
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const typescriptEslintPlugin = require("@typescript-eslint/eslint-plugin");
|
||||||
|
const typescriptEslintParser = require("@typescript-eslint/parser");
|
||||||
|
const prettierConfig = require("eslint-config-prettier");
|
||||||
|
|
||||||
|
module.exports = [
|
||||||
|
{
|
||||||
|
ignores: ["dist/**", "node_modules/**", "coverage/**", "prisma/migrations/**"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts", "prisma/**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
parser: typescriptEslintParser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
sourceType: "module",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": typescriptEslintPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...typescriptEslintPlugin.configs.recommended.rules,
|
||||||
|
...prettierConfig.rules,
|
||||||
|
"@typescript-eslint/explicit-function-return-type": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||||
|
"@typescript-eslint/no-non-null-assertion": "error",
|
||||||
|
"no-console": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
"*.ts": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{json,md,yml,yaml}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
};
|
||||||
Generated
+6948
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"name": "supporthub-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "SupportHub API — Production Enterprise Modular Monolith API",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/server.ts",
|
||||||
|
"development": "tsx --env-file=.env.development watch src/server.ts",
|
||||||
|
"dev:env": "tsx --env-file=.env.development watch src/server.ts",
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"start:prod": "NODE_ENV=production node dist/server.js",
|
||||||
|
"start:dev": "NODE_ENV=development tsx src/server.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"architecture:check": "tsx scripts/check-architecture.ts",
|
||||||
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\"",
|
||||||
|
"lint:fix": "eslint \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" --fix",
|
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"prisma/**/*.ts\"",
|
||||||
|
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"prisma/**/*.ts\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:env": "vitest run --env-file=.env.test",
|
||||||
|
"test:unit": "vitest run tests/unit",
|
||||||
|
"test:integration": "vitest run tests/integration",
|
||||||
|
"test:e2e": "vitest run tests/e2e",
|
||||||
|
"test:concurrency": "vitest run tests/concurrency",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:migrate": "prisma migrate dev",
|
||||||
|
"prisma:deploy": "prisma migrate deploy",
|
||||||
|
"prisma:seed": "tsx prisma/seed/index.ts",
|
||||||
|
"openapi:generate": "tsx scripts/generate-openapi.ts",
|
||||||
|
"docker:up": "docker compose -f docker-compose.dev.yml up -d",
|
||||||
|
"docker:down": "docker compose -f docker-compose.dev.yml down",
|
||||||
|
"docker:build:prod": "docker build -t supporthub-api:latest ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.556.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.556.0",
|
||||||
|
"@fastify/cors": "^9.0.1",
|
||||||
|
"@fastify/helmet": "^11.1.1",
|
||||||
|
"@fastify/rate-limit": "^9.1.0",
|
||||||
|
"@fastify/swagger": "^8.14.0",
|
||||||
|
"@fastify/swagger-ui": "^3.0.0",
|
||||||
|
"@opentelemetry/api": "^1.8.0",
|
||||||
|
"@opentelemetry/sdk-trace-base": "^1.22.0",
|
||||||
|
"@prisma/client": "^5.12.1",
|
||||||
|
"bullmq": "^5.7.1",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"fastify": "^4.26.2",
|
||||||
|
"fastify-plugin": "^4.5.1",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"pino": "^8.20.0",
|
||||||
|
"pino-pretty": "^11.0.0",
|
||||||
|
"prom-client": "^15.1.1",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.12.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.6.0",
|
||||||
|
"@typescript-eslint/parser": "^7.6.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"husky": "^9.0.11",
|
||||||
|
"lint-staged": "^15.2.2",
|
||||||
|
"prettier": "^3.2.5",
|
||||||
|
"prisma": "^5.12.1",
|
||||||
|
"tsx": "^4.7.2",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vitest": "^1.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
semi: true,
|
||||||
|
trailingComma: "all",
|
||||||
|
singleQuote: true,
|
||||||
|
printWidth: 100,
|
||||||
|
tabWidth: 2,
|
||||||
|
useTabs: false,
|
||||||
|
endOfLine: "lf",
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
ADMIN
|
||||||
|
AGENT
|
||||||
|
CUSTOMER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProductStatus {
|
||||||
|
ACTIVE
|
||||||
|
DEPRECATED
|
||||||
|
INACTIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
name String
|
||||||
|
role UserRole @default(CUSTOMER)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
auditLogs AuditLog[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
status ProductStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
categories Category[]
|
||||||
|
|
||||||
|
@@map("products")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
productId String
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@map("categories")
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String?
|
||||||
|
action String
|
||||||
|
resource String
|
||||||
|
payload Json?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@map("audit_logs")
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function seedCategories(prisma: PrismaClient): Promise<void> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(' -> Seeding baseline product categories...');
|
||||||
|
|
||||||
|
const product = await prisma.product.findUnique({
|
||||||
|
where: { code: 'CORE_PLATFORM' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!product) return;
|
||||||
|
|
||||||
|
const existingCategory = await prisma.category.findFirst({
|
||||||
|
where: { productId: product.id, name: 'General Support' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingCategory) {
|
||||||
|
await prisma.category.create({
|
||||||
|
data: {
|
||||||
|
productId: product.id,
|
||||||
|
name: 'General Support',
|
||||||
|
description: 'General support issues and inquiries',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { PrismaClient, UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function seedDemoData(prisma: PrismaClient): Promise<void> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(' -> Seeding demo environment data...');
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'john.doe@example.com' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'john.doe@example.com',
|
||||||
|
name: 'John Doe (Demo Customer)',
|
||||||
|
role: UserRole.CUSTOMER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function seedHierarchy(_prisma: PrismaClient): Promise<void> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(' -> Hierarchy seed skipped (schema reserved for orchestration domain phase).');
|
||||||
|
// Stub function - inactive until hierarchy schema models are introduced in domain phase.
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { seedRoles } from './roles.seed';
|
||||||
|
import { seedProducts } from './products.seed';
|
||||||
|
import { seedCategories } from './categories.seed';
|
||||||
|
import { seedHierarchy } from './hierarchy.seed';
|
||||||
|
import { seedDemoData } from './demo.seed';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('🚀 Starting SupportHub API database seeding...');
|
||||||
|
|
||||||
|
await seedRoles(prisma);
|
||||||
|
await seedProducts(prisma);
|
||||||
|
await seedCategories(prisma);
|
||||||
|
await seedHierarchy(prisma);
|
||||||
|
await seedDemoData(prisma);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('✅ Seeding completed successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('❌ Seeding failed with error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { PrismaClient, ProductStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function seedProducts(prisma: PrismaClient): Promise<void> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(' -> Seeding baseline products...');
|
||||||
|
|
||||||
|
await prisma.product.upsert({
|
||||||
|
where: { code: 'CORE_PLATFORM' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'CORE_PLATFORM',
|
||||||
|
name: 'Core SupportHub Platform',
|
||||||
|
description: 'Main enterprise ticketing and support engine',
|
||||||
|
status: ProductStatus.ACTIVE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { PrismaClient, UserRole } from '@prisma/client';
|
||||||
|
|
||||||
|
export async function seedRoles(prisma: PrismaClient): Promise<void> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(' -> Seeding baseline users & roles...');
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@supporthub.internal' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@supporthub.internal',
|
||||||
|
name: 'System Admin',
|
||||||
|
role: UserRole.ADMIN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'agent@supporthub.internal' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'agent@supporthub.internal',
|
||||||
|
name: 'Default Support Agent',
|
||||||
|
role: UserRole.AGENT,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const SRC_DIR = path.resolve(__dirname, '../src');
|
||||||
|
|
||||||
|
interface Violation {
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
rule: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const violations: Violation[] = [];
|
||||||
|
|
||||||
|
function getFilesRecursively(dir: string): string[] {
|
||||||
|
let results: string[] = [];
|
||||||
|
if (!fs.existsSync(dir)) return results;
|
||||||
|
const list = fs.readdirSync(dir);
|
||||||
|
for (const file of list) {
|
||||||
|
const filePath = path.join(dir, file);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (stat && stat.isDirectory()) {
|
||||||
|
results = results.concat(getFilesRecursively(filePath));
|
||||||
|
} else if (file.endsWith('.ts')) {
|
||||||
|
results.push(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkControllerDatabaseImports(filePath: string, content: string): void {
|
||||||
|
const relativePath = path.relative(SRC_DIR, filePath);
|
||||||
|
if (!relativePath.includes('controller')) return;
|
||||||
|
|
||||||
|
const lines = content.split('\n');
|
||||||
|
lines.forEach((line, idx) => {
|
||||||
|
if (
|
||||||
|
line.includes("from '@prisma/client'") ||
|
||||||
|
line.includes('from "@prisma/client"') ||
|
||||||
|
line.includes('prisma.client') ||
|
||||||
|
line.includes('prisma.plugin')
|
||||||
|
) {
|
||||||
|
violations.push({
|
||||||
|
file: relativePath,
|
||||||
|
line: idx + 1,
|
||||||
|
rule: 'NO_CONTROLLER_DIRECT_DB_ACCESS',
|
||||||
|
detail:
|
||||||
|
'Controllers must not directly import or access Prisma client. Use Services instead.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCrossModuleInternalImports(filePath: string, content: string): void {
|
||||||
|
const relativePath = path.relative(SRC_DIR, filePath);
|
||||||
|
const lines = content.split('\n');
|
||||||
|
|
||||||
|
lines.forEach((line, idx) => {
|
||||||
|
// Check for deep import from @/modules/group/module/internal
|
||||||
|
const moduleImportMatch = line.match(/from\s+['"]@\/modules\/([^'"]+)['"]/);
|
||||||
|
if (moduleImportMatch && moduleImportMatch[1]) {
|
||||||
|
const targetPath = moduleImportMatch[1];
|
||||||
|
const segments = targetPath.split('/');
|
||||||
|
// Allow @/modules/group/module or @/modules/group/module/index
|
||||||
|
if (segments.length > 2 && segments[2] !== 'index') {
|
||||||
|
violations.push({
|
||||||
|
file: relativePath,
|
||||||
|
line: idx + 1,
|
||||||
|
rule: 'NO_CROSS_MODULE_DEEP_IMPORTS',
|
||||||
|
detail: `Deep import '${targetPath}' bypasses module public API boundary (index.ts).`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runArchitectureCheck(): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('🔍 Running Architecture & Boundary Checks...');
|
||||||
|
|
||||||
|
const allFiles = getFilesRecursively(SRC_DIR);
|
||||||
|
|
||||||
|
for (const file of allFiles) {
|
||||||
|
const content = fs.readFileSync(file, 'utf-8');
|
||||||
|
checkControllerDatabaseImports(file, content);
|
||||||
|
checkCrossModuleInternalImports(file, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (violations.length > 0) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('\n❌ Architecture Violations Detected:\n');
|
||||||
|
violations.forEach((v) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(` - [${v.rule}] ${v.file}:${v.line}`);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(` ${v.detail}\n`);
|
||||||
|
});
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('✅ All architecture rules and module boundaries passed successfully!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runArchitectureCheck();
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { buildApp } from '../src/app';
|
||||||
|
|
||||||
|
async function generateOpenApiSpec(): Promise<void> {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('📝 Generating OpenAPI specification...');
|
||||||
|
const app = await buildApp();
|
||||||
|
|
||||||
|
await app.ready();
|
||||||
|
|
||||||
|
const swaggerSpec = app.swagger();
|
||||||
|
const outputPath = path.resolve(__dirname, '../docs/openapi.json');
|
||||||
|
|
||||||
|
const dir = path.dirname(outputPath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(outputPath, JSON.stringify(swaggerSpec, null, 2));
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`✅ OpenAPI specification exported to ${outputPath}`);
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
generateOpenApiSpec().catch((err: unknown) => {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('❌ Failed to generate OpenAPI spec:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
function runMigrations(): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('🔄 Deploying Prisma database migrations...');
|
||||||
|
try {
|
||||||
|
execSync('npx prisma migrate deploy', { stdio: 'inherit' });
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('✅ Migrations applied successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('❌ Error executing database migrations:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runMigrations();
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { execSync } from 'child_process';
|
||||||
|
|
||||||
|
function runSeed(): void {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('🌱 Executing database seeds...');
|
||||||
|
try {
|
||||||
|
execSync('npx tsx prisma/seed/index.ts', { stdio: 'inherit' });
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log('✅ Seed completed successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('❌ Error seeding database:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runSeed();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { healthService } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export async function healthRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
fastify.get('/health', async (_req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const status = await healthService.getReadinessStatus();
|
||||||
|
const statusCode = status.status === 'ok' ? 200 : status.status === 'degraded' ? 200 : 503;
|
||||||
|
return reply.status(statusCode).send(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/health/live', async (_req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const status = await healthService.getLiveStatus();
|
||||||
|
return reply.status(200).send(status);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/health/ready', async (_req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const status = await healthService.getReadinessStatus();
|
||||||
|
const statusCode = status.status === 'ok' ? 200 : 503;
|
||||||
|
return reply.status(statusCode).send(status);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './health.routes';
|
||||||
|
export * from './metrics.routes';
|
||||||
|
export * from './routes';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { metricsRegistry } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export async function metricsRoutes(fastify: FastifyInstance): Promise<void> {
|
||||||
|
fastify.get('/metrics', async (_req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const metrics = await metricsRegistry.metrics();
|
||||||
|
return reply.header('Content-Type', metricsRegistry.contentType).send(metrics);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { healthRoutes } from './health.routes';
|
||||||
|
import { metricsRoutes } from './metrics.routes';
|
||||||
|
|
||||||
|
export async function registerGlobalRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
await app.register(healthRoutes);
|
||||||
|
await app.register(metricsRoutes);
|
||||||
|
// Domain module routes will be registered here as feature modules are wired up
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import { env } from '@/config';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
import { AppError } from '@/common/errors';
|
||||||
|
import { bootstrapPlugins, bootstrapRoutes } from '@/bootstrap';
|
||||||
|
|
||||||
|
export async function buildApp(): Promise<FastifyInstance> {
|
||||||
|
const app = Fastify({
|
||||||
|
logger: false, // Managed centrally via Pino logger instance
|
||||||
|
disableRequestLogging: false,
|
||||||
|
requestIdHeader: 'x-request-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register Bootstrapped Plugins
|
||||||
|
await bootstrapPlugins(app);
|
||||||
|
|
||||||
|
// Register Bootstrapped Global Routes
|
||||||
|
await bootstrapRoutes(app);
|
||||||
|
|
||||||
|
// Global Error Handler
|
||||||
|
app.setErrorHandler(
|
||||||
|
(error: Error | AppError | ZodError, request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const requestId = request.reqContext?.requestId || (request.id as string) || 'unknown';
|
||||||
|
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
logger.warn(
|
||||||
|
{ code: error.code, statusCode: error.statusCode, requestId, details: error.details },
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
return reply.status(error.statusCode).send({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
details: error.details,
|
||||||
|
},
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
logger.warn({ requestId, issues: error.issues }, 'Request Validation Error');
|
||||||
|
return reply.status(400).send({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: 'Invalid request payload or parameters',
|
||||||
|
details: error.issues,
|
||||||
|
},
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error({ error, requestId }, 'Unhandled Server Error');
|
||||||
|
|
||||||
|
const responseMessage =
|
||||||
|
env.NODE_ENV === 'production'
|
||||||
|
? 'Internal server error occurred'
|
||||||
|
: error.message || 'Internal server error';
|
||||||
|
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: responseMessage,
|
||||||
|
details: null,
|
||||||
|
},
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 404 Not Found Handler
|
||||||
|
app.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const requestId = request.reqContext?.requestId || (request.id as string) || 'unknown';
|
||||||
|
return reply.status(404).send({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: `Route ${request.method} ${request.url} not found`,
|
||||||
|
details: null,
|
||||||
|
},
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { prismaClient } from '@/infrastructure/database';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export async function bootstrapDatabase(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await prismaClient.$connect();
|
||||||
|
logger.info('Database (Prisma) initialized successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to initialize Database connection.');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export * from './database.bootstrap';
|
||||||
|
export * from './redis.bootstrap';
|
||||||
|
export * from './queue.bootstrap';
|
||||||
|
export * from './storage.bootstrap';
|
||||||
|
export * from './plugins.bootstrap';
|
||||||
|
export * from './routes.bootstrap';
|
||||||
|
export * from './shutdown.bootstrap';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import {
|
||||||
|
prismaPlugin,
|
||||||
|
authPlugin,
|
||||||
|
swaggerPlugin,
|
||||||
|
corsPlugin,
|
||||||
|
helmetPlugin,
|
||||||
|
rateLimitPlugin,
|
||||||
|
requestContextPlugin,
|
||||||
|
} from '@/plugins';
|
||||||
|
|
||||||
|
export async function bootstrapPlugins(app: FastifyInstance): Promise<void> {
|
||||||
|
await app.register(requestContextPlugin);
|
||||||
|
await app.register(corsPlugin);
|
||||||
|
await app.register(helmetPlugin);
|
||||||
|
await app.register(rateLimitPlugin);
|
||||||
|
await app.register(prismaPlugin);
|
||||||
|
await app.register(authPlugin);
|
||||||
|
await app.register(swaggerPlugin);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export async function bootstrapQueue(): Promise<void> {
|
||||||
|
logger.info('Queue Manager initialized.');
|
||||||
|
// Ready to register workers as domain features are introduced
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { redisClient } from '@/infrastructure/cache';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export async function bootstrapRedis(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await redisClient.connect();
|
||||||
|
logger.info('Redis cache client initialized successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Failed to initialize Redis connection.');
|
||||||
|
// Lazy connect fallback: log warning if already connected or deferred
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { registerGlobalRoutes } from '@/api/routes';
|
||||||
|
|
||||||
|
export async function bootstrapRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
await registerGlobalRoutes(app);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { FastifyInstance } from 'fastify';
|
||||||
|
import { prismaClient } from '@/infrastructure/database';
|
||||||
|
import { redisClient } from '@/infrastructure/cache';
|
||||||
|
import { queueManager } from '@/infrastructure/queue';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export function setupGracefulShutdown(app: FastifyInstance): void {
|
||||||
|
const handleShutdown = async (signal: string): Promise<void> => {
|
||||||
|
logger.info({ signal }, 'Received termination signal. Starting graceful shutdown...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.close();
|
||||||
|
logger.info('Fastify server closed.');
|
||||||
|
|
||||||
|
await queueManager.shutdown();
|
||||||
|
logger.info('Queue workers closed.');
|
||||||
|
|
||||||
|
await redisClient.quit();
|
||||||
|
logger.info('Redis client disconnected.');
|
||||||
|
|
||||||
|
await prismaClient.$disconnect();
|
||||||
|
logger.info('Prisma database client disconnected.');
|
||||||
|
|
||||||
|
logger.info('Graceful shutdown completed. Exiting process.');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, 'Error occurred during graceful shutdown.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => handleShutdown('SIGINT'));
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { storageService } from '@/infrastructure/storage';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export async function bootstrapStorage(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await storageService.ensureBucketExists();
|
||||||
|
logger.info('AWS S3 Object Storage initialized.');
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn({ error }, 'AWS S3 initialization deferred or bucket check bypassed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export const APP_CONSTANTS = {
|
||||||
|
DEFAULT_PAGE: 1,
|
||||||
|
DEFAULT_LIMIT: 20,
|
||||||
|
MAX_LIMIT: 100,
|
||||||
|
CORRELATION_HEADER: 'x-correlation-id',
|
||||||
|
REQUEST_ID_HEADER: 'x-request-id',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './app.constants';
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export enum Environment {
|
||||||
|
DEVELOPMENT = 'development',
|
||||||
|
TEST = 'test',
|
||||||
|
STAGING = 'staging',
|
||||||
|
PRODUCTION = 'production',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ActorType {
|
||||||
|
USER = 'USER',
|
||||||
|
AGENT = 'AGENT',
|
||||||
|
CUSTOMER = 'CUSTOMER',
|
||||||
|
SYSTEM = 'SYSTEM',
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
export class AppError extends Error {
|
||||||
|
public readonly code: string;
|
||||||
|
public readonly statusCode: number;
|
||||||
|
public readonly details: unknown | null;
|
||||||
|
public readonly metadata: Record<string, unknown> | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
code = 'INTERNAL_SERVER_ERROR',
|
||||||
|
statusCode = 500,
|
||||||
|
details: unknown | null = null,
|
||||||
|
metadata: Record<string, unknown> | null = null,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
Object.setPrototypeOf(this, new.target.prototype);
|
||||||
|
this.name = this.constructor.name;
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.details = details;
|
||||||
|
this.metadata = metadata;
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { AppError } from './app.error';
|
||||||
|
|
||||||
|
export class AuthorizationError extends AppError {
|
||||||
|
constructor(message = 'Forbidden access', details: unknown | null = null) {
|
||||||
|
super(message, 'FORBIDDEN', 403, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthenticationError extends AppError {
|
||||||
|
constructor(message = 'Unauthorized access', details: unknown | null = null) {
|
||||||
|
super(message, 'UNAUTHORIZED', 401, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './app.error';
|
||||||
|
export * from './validation.error';
|
||||||
|
export * from './authorization.error';
|
||||||
|
export * from './not-found.error';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { AppError } from './app.error';
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(message = 'Resource not found', details: unknown | null = null) {
|
||||||
|
super(message, 'NOT_FOUND', 404, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConflictError extends AppError {
|
||||||
|
constructor(message = 'Resource conflict', details: unknown | null = null) {
|
||||||
|
super(message, 'CONFLICT', 409, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RateLimitError extends AppError {
|
||||||
|
constructor(message = 'Too many requests', details: unknown | null = null) {
|
||||||
|
super(message, 'RATE_LIMIT_EXCEEDED', 429, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { AppError } from './app.error';
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message = 'Validation Error', details: unknown | null = null) {
|
||||||
|
super(message, 'VALIDATION_ERROR', 400, details);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ActorType } from '../enums';
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
actorType: ActorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JwtPayload {
|
||||||
|
sub: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
actorType: ActorType;
|
||||||
|
iat?: number;
|
||||||
|
exp?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './request-context.types';
|
||||||
|
export * from './pagination.types';
|
||||||
|
export * from './auth.types';
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface PaginationParams {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationMeta {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
hasPrevPage: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
data: T[];
|
||||||
|
meta: PaginationMeta;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ActorType } from '../enums';
|
||||||
|
|
||||||
|
export interface RequestContext {
|
||||||
|
requestId: string;
|
||||||
|
correlationId: string;
|
||||||
|
actorId?: string;
|
||||||
|
actorType?: ActorType;
|
||||||
|
productId?: string;
|
||||||
|
customerId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function formatDateToIso(date: Date = new Date()): string {
|
||||||
|
return date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addMinutes(date: Date, minutes: number): Date {
|
||||||
|
return new Date(date.getTime() + minutes * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addHours(date: Date, hours: number): Date {
|
||||||
|
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
export function generateUuid(): string {
|
||||||
|
return randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateUlidLikeId(prefix = 'id'): string {
|
||||||
|
const timestamp = Date.now().toString(36);
|
||||||
|
const randomStr = Math.random().toString(36).substring(2, 8);
|
||||||
|
return `${prefix}_${timestamp}${randomStr}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './date.utils';
|
||||||
|
export * from './id.utils';
|
||||||
|
export * from './pagination.utils';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { APP_CONSTANTS } from '../constants';
|
||||||
|
import { PaginationMeta, PaginationParams } from '../types';
|
||||||
|
|
||||||
|
export function normalizePagination(params: PaginationParams = {}): {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
skip: number;
|
||||||
|
} {
|
||||||
|
const page = Math.max(1, params.page || APP_CONSTANTS.DEFAULT_PAGE);
|
||||||
|
const limit = Math.min(
|
||||||
|
APP_CONSTANTS.MAX_LIMIT,
|
||||||
|
Math.max(1, params.limit || APP_CONSTANTS.DEFAULT_LIMIT),
|
||||||
|
);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
return { page, limit, skip };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPaginationMeta(
|
||||||
|
totalItems: number,
|
||||||
|
page: number,
|
||||||
|
limit: number,
|
||||||
|
): PaginationMeta {
|
||||||
|
const totalPages = Math.ceil(totalItems / limit) || 1;
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalItems,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPrevPage: page > 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export const databaseConfig = {
|
||||||
|
url: env.DATABASE_URL,
|
||||||
|
maxConnections: env.NODE_ENV === 'production' ? 50 : 10,
|
||||||
|
};
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
NODE_ENV: z.enum(['development', 'test', 'staging', 'production']).default('development'),
|
||||||
|
PORT: z.coerce.number().default(3000),
|
||||||
|
HOST: z.string().default('0.0.0.0'),
|
||||||
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'),
|
||||||
|
|
||||||
|
DATABASE_URL: z.string().url(),
|
||||||
|
|
||||||
|
REDIS_HOST: z.string().default('localhost'),
|
||||||
|
REDIS_PORT: z.coerce.number().default(6379),
|
||||||
|
REDIS_PASSWORD: z.string().optional(),
|
||||||
|
|
||||||
|
JWT_SECRET: z.string().min(16),
|
||||||
|
JWT_ACCESS_EXPIRES: z.string().default('15m'),
|
||||||
|
JWT_REFRESH_EXPIRES: z.string().default('7d'),
|
||||||
|
|
||||||
|
// AWS S3 Configuration
|
||||||
|
AWS_REGION: z.string().default('us-east-1'),
|
||||||
|
AWS_S3_BUCKET: z.string().default('supporthub-attachments'),
|
||||||
|
AWS_ACCESS_KEY_ID: z.string().default('mock-aws-access-key-id'),
|
||||||
|
AWS_SECRET_ACCESS_KEY: z.string().default('mock-aws-secret-access-key'),
|
||||||
|
AWS_S3_ENDPOINT: z.string().optional(),
|
||||||
|
|
||||||
|
CORS_ORIGINS: z
|
||||||
|
.string()
|
||||||
|
.transform((val) => val.split(',').map((origin) => origin.trim()))
|
||||||
|
.default('http://localhost:3000'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EnvConfig = z.infer<typeof envSchema>;
|
||||||
|
|
||||||
|
function parseEnv(): EnvConfig {
|
||||||
|
const result = envSchema.safeParse(process.env);
|
||||||
|
if (!result.success) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(
|
||||||
|
'❌ Invalid environment variables:',
|
||||||
|
JSON.stringify(result.error.format(), null, 2),
|
||||||
|
);
|
||||||
|
throw new Error('Environment configuration validation failed.');
|
||||||
|
}
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = parseEnv();
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './env';
|
||||||
|
export * from './database';
|
||||||
|
export * from './redis';
|
||||||
|
export * from './queue';
|
||||||
|
export * from './storage';
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { redisConfig } from './redis';
|
||||||
|
|
||||||
|
export const queueConfig = {
|
||||||
|
connection: redisConfig,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 1000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 500,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export const redisConfig = {
|
||||||
|
host: env.REDIS_HOST,
|
||||||
|
port: env.REDIS_PORT,
|
||||||
|
password: env.REDIS_PASSWORD || undefined,
|
||||||
|
maxRetriesPerRequest: null,
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export const storageConfig = {
|
||||||
|
region: env.AWS_REGION,
|
||||||
|
bucketName: env.AWS_S3_BUCKET,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.AWS_ACCESS_KEY_ID,
|
||||||
|
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
|
||||||
|
},
|
||||||
|
endpoint: env.AWS_S3_ENDPOINT || undefined,
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export enum DomainEventName {
|
||||||
|
TICKET_CREATED = 'ticket.created',
|
||||||
|
TICKET_UPDATED = 'ticket.updated',
|
||||||
|
TICKET_ASSIGNED = 'ticket.assigned',
|
||||||
|
TICKET_RESOLVED = 'ticket.resolved',
|
||||||
|
PROBLEM_IDENTIFIED = 'problem.identified',
|
||||||
|
SLA_BREACHED = 'sla.breached',
|
||||||
|
ESCALATION_TRIGGERED = 'escalation.triggered',
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import EventEmitter from 'events';
|
||||||
|
import { BaseDomainEvent } from './event-types';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export class EventBus {
|
||||||
|
private emitter: EventEmitter;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.emitter = new EventEmitter();
|
||||||
|
this.emitter.setMaxListeners(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
publish<T>(event: BaseDomainEvent<T>): void {
|
||||||
|
logger.info({ eventName: event.eventName, eventId: event.eventId }, 'Publishing domain event');
|
||||||
|
this.emitter.emit(event.eventName, event);
|
||||||
|
this.emitter.emit('*', event);
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe<T>(
|
||||||
|
eventName: string,
|
||||||
|
handler: (event: BaseDomainEvent<T>) => Promise<void> | void,
|
||||||
|
): void {
|
||||||
|
this.emitter.on(eventName, async (event: BaseDomainEvent<T>) => {
|
||||||
|
try {
|
||||||
|
await handler(event);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, eventName, eventId: event.eventId }, 'Error executing event handler');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventBus = new EventBus();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface BaseDomainEvent<T = unknown> {
|
||||||
|
eventId: string;
|
||||||
|
eventName: string;
|
||||||
|
aggregateId: string;
|
||||||
|
aggregateType: string;
|
||||||
|
timestamp: string;
|
||||||
|
payload: T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function registerDomainEventHandlers(): void {
|
||||||
|
// Skeleton for registering domain event listeners during feature module implementation
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './event-types';
|
||||||
|
export * from './domain-events';
|
||||||
|
export * from './event-bus';
|
||||||
|
export * from './handlers';
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
import { redisClient } from './redis.client';
|
||||||
|
|
||||||
|
export class CacheService {
|
||||||
|
constructor(private readonly redis: Redis = redisClient) {}
|
||||||
|
|
||||||
|
async get<T>(key: string): Promise<T | null> {
|
||||||
|
const data = await this.redis.get(key);
|
||||||
|
if (!data) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(data) as T;
|
||||||
|
} catch {
|
||||||
|
return data as unknown as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||||
|
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
|
||||||
|
if (ttlSeconds && ttlSeconds > 0) {
|
||||||
|
await this.redis.set(key, serialized, 'EX', ttlSeconds);
|
||||||
|
} else {
|
||||||
|
await this.redis.set(key, serialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async del(key: string): Promise<void> {
|
||||||
|
await this.redis.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async exists(key: string): Promise<boolean> {
|
||||||
|
const count = await this.redis.exists(key);
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cacheService = new CacheService();
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './redis.client';
|
||||||
|
export * from './cache.service';
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
import Redis from 'ioredis';
|
||||||
|
import { redisConfig } from '@/config';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __redisClient: Redis | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRedisClient(): Redis {
|
||||||
|
if (!global.__redisClient) {
|
||||||
|
global.__redisClient = new Redis({
|
||||||
|
host: redisConfig.host,
|
||||||
|
port: redisConfig.port,
|
||||||
|
password: redisConfig.password,
|
||||||
|
maxRetriesPerRequest: redisConfig.maxRetriesPerRequest,
|
||||||
|
lazyConnect: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return global.__redisClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const redisClient = createRedisClient();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './prisma.client';
|
||||||
|
export * from './transaction.service';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { env } from '@/config';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __prismaClient: PrismaClient | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrismaClient(): PrismaClient {
|
||||||
|
if (env.NODE_ENV === 'production') {
|
||||||
|
return new PrismaClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!global.__prismaClient) {
|
||||||
|
global.__prismaClient = new PrismaClient({
|
||||||
|
log: ['error', 'warn'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return global.__prismaClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const prismaClient = createPrismaClient();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Prisma, PrismaClient } from '@prisma/client';
|
||||||
|
import { prismaClient } from './prisma.client';
|
||||||
|
|
||||||
|
export class TransactionService {
|
||||||
|
constructor(private readonly prisma: PrismaClient = prismaClient) {}
|
||||||
|
|
||||||
|
async runInTransaction<T>(
|
||||||
|
fn: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||||
|
options?: { maxWait?: number; timeout?: number },
|
||||||
|
): Promise<T> {
|
||||||
|
return this.prisma.$transaction(fn, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transactionService = new TransactionService();
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { prismaClient } from '../database/prisma.client';
|
||||||
|
import { redisClient } from '../cache/redis.client';
|
||||||
|
|
||||||
|
export interface ComponentHealth {
|
||||||
|
status: 'up' | 'down';
|
||||||
|
latencyMs?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthStatus {
|
||||||
|
status: 'ok' | 'degraded' | 'error';
|
||||||
|
timestamp: string;
|
||||||
|
uptime: number;
|
||||||
|
components: Record<string, ComponentHealth>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class HealthService {
|
||||||
|
async getLiveStatus(): Promise<{ status: 'ok'; timestamp: string }> {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReadinessStatus(): Promise<HealthStatus> {
|
||||||
|
const components: Record<string, ComponentHealth> = {};
|
||||||
|
|
||||||
|
// Check PostgreSQL DB
|
||||||
|
try {
|
||||||
|
const dbStart = Date.now();
|
||||||
|
await prismaClient.$queryRaw`SELECT 1`;
|
||||||
|
components['database'] = {
|
||||||
|
status: 'up',
|
||||||
|
latencyMs: Date.now() - dbStart,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
components['database'] = {
|
||||||
|
status: 'down',
|
||||||
|
error: err instanceof Error ? err.message : 'Database connection failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Redis Cache
|
||||||
|
try {
|
||||||
|
const redisStart = Date.now();
|
||||||
|
await redisClient.ping();
|
||||||
|
components['redis'] = {
|
||||||
|
status: 'up',
|
||||||
|
latencyMs: Date.now() - redisStart,
|
||||||
|
};
|
||||||
|
} catch (err: unknown) {
|
||||||
|
components['redis'] = {
|
||||||
|
status: 'down',
|
||||||
|
error: err instanceof Error ? err.message : 'Redis connection failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllUp = Object.values(components).every((c) => c.status === 'up');
|
||||||
|
const isAnyUp = Object.values(components).some((c) => c.status === 'up');
|
||||||
|
|
||||||
|
let overallStatus: 'ok' | 'degraded' | 'error' = 'ok';
|
||||||
|
if (!isAllUp) {
|
||||||
|
overallStatus = isAnyUp ? 'degraded' : 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: overallStatus,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
uptime: process.uptime(),
|
||||||
|
components,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const healthService = new HealthService();
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './logger';
|
||||||
|
export * from './metrics';
|
||||||
|
export * from './tracing';
|
||||||
|
export * from './health.service';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import pino from 'pino';
|
||||||
|
import { env } from '@/config';
|
||||||
|
|
||||||
|
const pinoOptions: pino.LoggerOptions = {
|
||||||
|
level: env.LOG_LEVEL,
|
||||||
|
base: {
|
||||||
|
env: env.NODE_ENV,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (env.NODE_ENV === 'development') {
|
||||||
|
pinoOptions.transport = {
|
||||||
|
target: 'pino-pretty',
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: 'SYS:standard',
|
||||||
|
ignore: 'pid,hostname',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = pino(pinoOptions);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import client from 'prom-client';
|
||||||
|
|
||||||
|
client.collectDefaultMetrics({ prefix: 'supporthub_' });
|
||||||
|
|
||||||
|
export const httpRequestDurationHistogram = new client.Histogram({
|
||||||
|
name: 'supporthub_http_request_duration_seconds',
|
||||||
|
help: 'Duration of HTTP requests in seconds',
|
||||||
|
labelNames: ['method', 'route', 'status_code'],
|
||||||
|
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 3, 5],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metricsRegistry = client.register;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { trace, Tracer } from '@opentelemetry/api';
|
||||||
|
|
||||||
|
export function getTracer(name = 'supporthub-api'): Tracer {
|
||||||
|
return trace.getTracer(name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './queue.types';
|
||||||
|
export * from './queue.manager';
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Queue, Worker, Processor, Job } from 'bullmq';
|
||||||
|
import { queueConfig } from '@/config';
|
||||||
|
import { QueueName, QueueJobPayload } from './queue.types';
|
||||||
|
|
||||||
|
export class QueueManager {
|
||||||
|
private queues: Map<string, Queue> = new Map();
|
||||||
|
private workers: Map<string, Worker> = new Map();
|
||||||
|
|
||||||
|
getQueue<T = unknown>(name: QueueName): Queue<QueueJobPayload<T>> {
|
||||||
|
if (!this.queues.has(name)) {
|
||||||
|
const queue = new Queue<QueueJobPayload<T>>(name, {
|
||||||
|
connection: queueConfig.connection,
|
||||||
|
defaultJobOptions: queueConfig.defaultJobOptions,
|
||||||
|
});
|
||||||
|
this.queues.set(name, queue as Queue);
|
||||||
|
}
|
||||||
|
return this.queues.get(name) as Queue<QueueJobPayload<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerWorker<T = unknown>(
|
||||||
|
name: QueueName,
|
||||||
|
processor: Processor<QueueJobPayload<T>>,
|
||||||
|
): Worker<QueueJobPayload<T>> {
|
||||||
|
if (this.workers.has(name)) {
|
||||||
|
return this.workers.get(name) as Worker<QueueJobPayload<T>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const worker = new Worker<QueueJobPayload<T>>(name, processor, {
|
||||||
|
connection: queueConfig.connection,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.workers.set(name, worker as Worker);
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addJob<T = unknown>(
|
||||||
|
queueName: QueueName,
|
||||||
|
jobName: string,
|
||||||
|
data: T,
|
||||||
|
): Promise<Job<QueueJobPayload<T>>> {
|
||||||
|
const queue = this.getQueue<T>(queueName);
|
||||||
|
const payload: QueueJobPayload<T> = {
|
||||||
|
jobId: `${jobName}_${Date.now()}`,
|
||||||
|
type: jobName,
|
||||||
|
payload: data,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return queue.add(jobName, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
async shutdown(): Promise<void> {
|
||||||
|
for (const worker of this.workers.values()) {
|
||||||
|
await worker.close();
|
||||||
|
}
|
||||||
|
for (const queue of this.queues.values()) {
|
||||||
|
await queue.close();
|
||||||
|
}
|
||||||
|
this.workers.clear();
|
||||||
|
this.queues.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queueManager = new QueueManager();
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export enum QueueName {
|
||||||
|
SLA = 'sla-queue',
|
||||||
|
ESCALATION = 'escalation-queue',
|
||||||
|
NOTIFICATIONS = 'notifications-queue',
|
||||||
|
ATTACHMENTS = 'attachments-queue',
|
||||||
|
ANALYTICS = 'analytics-queue',
|
||||||
|
CLEANUP = 'cleanup-queue',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueJobPayload<T = unknown> {
|
||||||
|
jobId: string;
|
||||||
|
type: string;
|
||||||
|
payload: T;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './storage.client';
|
||||||
|
export * from './storage.service';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { S3Client, S3ClientConfig } from '@aws-sdk/client-s3';
|
||||||
|
import { storageConfig } from '@/config';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line no-var
|
||||||
|
var __storageClient: S3Client | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createStorageClient(): S3Client {
|
||||||
|
if (!global.__storageClient) {
|
||||||
|
const config: S3ClientConfig = {
|
||||||
|
region: storageConfig.region,
|
||||||
|
credentials: storageConfig.credentials,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (storageConfig.endpoint) {
|
||||||
|
config.endpoint = storageConfig.endpoint;
|
||||||
|
config.forcePathStyle = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
global.__storageClient = new S3Client(config);
|
||||||
|
}
|
||||||
|
return global.__storageClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storageClient = createStorageClient();
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import {
|
||||||
|
S3Client,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
CreateBucketCommand,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||||
|
import { storageConfig } from '@/config';
|
||||||
|
import { storageClient } from './storage.client';
|
||||||
|
|
||||||
|
export class StorageService {
|
||||||
|
constructor(
|
||||||
|
private readonly client: S3Client = storageClient,
|
||||||
|
private readonly defaultBucket: string = storageConfig.bucketName,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async ensureBucketExists(bucketName: string = this.defaultBucket): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.client.send(new HeadBucketCommand({ Bucket: bucketName }));
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await this.client.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||||
|
} catch {
|
||||||
|
// Ignore if bucket creation isn't permitted or already exists in AWS environment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(
|
||||||
|
objectName: string,
|
||||||
|
body: Buffer | Uint8Array | Blob | string,
|
||||||
|
contentType?: string,
|
||||||
|
bucketName: string = this.defaultBucket,
|
||||||
|
): Promise<string> {
|
||||||
|
await this.ensureBucketExists(bucketName);
|
||||||
|
const command = new PutObjectCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Key: objectName,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType,
|
||||||
|
});
|
||||||
|
await this.client.send(command);
|
||||||
|
return objectName;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPresignedUrl(
|
||||||
|
objectName: string,
|
||||||
|
expirySeconds = 3600,
|
||||||
|
bucketName: string = this.defaultBucket,
|
||||||
|
): Promise<string> {
|
||||||
|
const command = new GetObjectCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Key: objectName,
|
||||||
|
});
|
||||||
|
return getSignedUrl(this.client, command, { expiresIn: expirySeconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(objectName: string, bucketName: string = this.defaultBucket): Promise<void> {
|
||||||
|
const command = new DeleteObjectCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Key: objectName,
|
||||||
|
});
|
||||||
|
await this.client.send(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storageService = new StorageService();
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export function registerAnalyticsWorker(): void {
|
||||||
|
queueManager.registerWorker(QueueName.ANALYTICS, async (job) => {
|
||||||
|
logger.info({ jobId: job.id, data: job.data }, 'Processing Analytics Job');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export function registerAttachmentWorker(): void {
|
||||||
|
queueManager.registerWorker(QueueName.ATTACHMENTS, async (job) => {
|
||||||
|
logger.info({ jobId: job.id, data: job.data }, 'Processing Attachment Job');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export function registerCleanupWorker(): void {
|
||||||
|
queueManager.registerWorker(QueueName.CLEANUP, async (job) => {
|
||||||
|
logger.info({ jobId: job.id, data: job.data }, 'Processing Cleanup Job');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export function registerEscalationWorker(): void {
|
||||||
|
queueManager.registerWorker(QueueName.ESCALATION, async (job) => {
|
||||||
|
logger.info({ jobId: job.id, data: job.data }, 'Processing Escalation Job');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './sla';
|
||||||
|
export * from './escalation';
|
||||||
|
export * from './notifications';
|
||||||
|
export * from './attachments';
|
||||||
|
export * from './analytics';
|
||||||
|
export * from './cleanup';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export function registerNotificationWorker(): void {
|
||||||
|
queueManager.registerWorker(QueueName.NOTIFICATIONS, async (job) => {
|
||||||
|
logger.info({ jobId: job.id, data: job.data }, 'Processing Notification Job');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { queueManager, QueueName } from '@/infrastructure/queue';
|
||||||
|
import { logger } from '@/infrastructure/observability';
|
||||||
|
|
||||||
|
export function registerSlaWorker(): void {
|
||||||
|
queueManager.registerWorker(QueueName.SLA, async (job) => {
|
||||||
|
logger.info({ jobId: job.id, data: job.data }, 'Processing SLA Job');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const CATEGORIES_CONSTANTS = {
|
||||||
|
MODULE_NAME: 'CATALOG_CATEGORIES',
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import { categoriesService, CategoriesService } from '../service';
|
||||||
|
|
||||||
|
export class CategoriesController {
|
||||||
|
constructor(private readonly service: CategoriesService = categoriesService) {}
|
||||||
|
|
||||||
|
async getCategories(_request: FastifyRequest, reply: FastifyReply) {
|
||||||
|
const categories = await this.service.listCategories();
|
||||||
|
return reply.status(200).send({
|
||||||
|
success: true,
|
||||||
|
data: categories,
|
||||||
|
meta: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const categoriesController = new CategoriesController();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './categories.controller';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { categoriesRoutes } from './routes';
|
||||||
|
export { CategoriesService, categoriesService } from './service';
|
||||||
|
export type { CategoryDTO } from './types';
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class CategoryMapper {
|
||||||
|
static toDTO<T>(data: T): T {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { prismaClient } from '@/infrastructure/database';
|
||||||
|
|
||||||
|
export class CategoriesRepository {
|
||||||
|
constructor(private readonly prisma = prismaClient) {}
|
||||||
|
|
||||||
|
async findAllCategories(): Promise<unknown[]> {
|
||||||
|
return this.prisma.category.findMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const categoriesRepository = new CategoriesRepository();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './categories.repository';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user