first commit

This commit is contained in:
saqib mir
2026-08-19 16:29:17 +05:30
commit 5e4ed9d64a
320 changed files with 10520 additions and 0 deletions
+17
View File
@@ -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
+12
View File
@@ -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
+16
View File
@@ -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
+26
View File
@@ -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=*
+10
View File
@@ -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
View File
@@ -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/
+2
View File
@@ -0,0 +1,2 @@
npx lint-staged
npx tsx scripts/check-architecture.ts
+65
View File
@@ -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
View File
@@ -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"]
+12
View File
@@ -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
+88
View File
@@ -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:
+88
View File
@@ -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:
+88
View File
@@ -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:
+31
View File
@@ -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",
},
},
];
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
"*.ts": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
};
+6948
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -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
}
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
semi: true,
trailingComma: "all",
singleQuote: true,
printWidth: 100,
tabWidth: 2,
useTabs: false,
endOfLine: "lf",
};
+73
View File
@@ -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")
}
+26
View File
@@ -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',
},
});
}
}
+16
View File
@@ -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,
},
});
}
+7
View File
@@ -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.
}
+32
View File
@@ -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();
});
+17
View File
@@ -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,
},
});
}
+26
View File
@@ -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,
},
});
}
+105
View File
@@ -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();
+30
View File
@@ -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);
});
+17
View File
@@ -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();
+17
View File
@@ -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();
+21
View File
@@ -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);
});
}
+3
View File
@@ -0,0 +1,3 @@
export * from './health.routes';
export * from './metrics.routes';
export * from './routes';
+9
View File
@@ -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);
});
}
+9
View File
@@ -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
View File
@@ -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;
}
+12
View File
@@ -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;
}
}
+7
View File
@@ -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';
+20
View File
@@ -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);
}
+6
View File
@@ -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
}
+12
View File
@@ -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
}
}
+6
View File
@@ -0,0 +1,6 @@
import { FastifyInstance } from 'fastify';
import { registerGlobalRoutes } from '@/api/routes';
export async function bootstrapRoutes(app: FastifyInstance): Promise<void> {
await registerGlobalRoutes(app);
}
+34
View File
@@ -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'));
}
+11
View File
@@ -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.');
}
}
+7
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export * from './app.constants';
+13
View File
@@ -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',
}
+23
View File
@@ -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);
}
}
+13
View File
@@ -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);
}
}
+4
View File
@@ -0,0 +1,4 @@
export * from './app.error';
export * from './validation.error';
export * from './authorization.error';
export * from './not-found.error';
+19
View File
@@ -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);
}
}
+7
View File
@@ -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);
}
}
+17
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './request-context.types';
export * from './pagination.types';
export * from './auth.types';
+18
View File
@@ -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;
}
+11
View File
@@ -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;
}
+11
View File
@@ -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);
}
+11
View File
@@ -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}`;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './date.utils';
export * from './id.utils';
export * from './pagination.utils';
+33
View File
@@ -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,
};
}
+6
View File
@@ -0,0 +1,6 @@
import { env } from './env';
export const databaseConfig = {
url: env.DATABASE_URL,
maxConnections: env.NODE_ENV === 'production' ? 50 : 10,
};
+50
View File
@@ -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();
+5
View File
@@ -0,0 +1,5 @@
export * from './env';
export * from './database';
export * from './redis';
export * from './queue';
export * from './storage';
+14
View File
@@ -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,
},
};
+8
View File
@@ -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,
};
+11
View File
@@ -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,
};
+9
View File
@@ -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',
}
+33
View File
@@ -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();
+8
View File
@@ -0,0 +1,8 @@
export interface BaseDomainEvent<T = unknown> {
eventId: string;
eventName: string;
aggregateId: string;
aggregateType: string;
timestamp: string;
payload: T;
}
+3
View File
@@ -0,0 +1,3 @@
export function registerDomainEventHandlers(): void {
// Skeleton for registering domain event listeners during feature module implementation
}
+4
View File
@@ -0,0 +1,4 @@
export * from './event-types';
export * from './domain-events';
export * from './event-bus';
export * from './handlers';
+36
View File
@@ -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();
+2
View File
@@ -0,0 +1,2 @@
export * from './redis.client';
export * from './cache.service';
+22
View File
@@ -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();
+2
View File
@@ -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);
}
+2
View File
@@ -0,0 +1,2 @@
export * from './queue.types';
export * from './queue.manager';
+63
View File
@@ -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();
+15
View File
@@ -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;
}
+2
View File
@@ -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();
+8
View File
@@ -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');
});
}
+8
View File
@@ -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');
});
}
+8
View File
@@ -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');
});
}
+8
View File
@@ -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');
});
}
+6
View File
@@ -0,0 +1,6 @@
export * from './sla';
export * from './escalation';
export * from './notifications';
export * from './attachments';
export * from './analytics';
export * from './cleanup';
+8
View File
@@ -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');
});
}
+8
View File
@@ -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';
+3
View File
@@ -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