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