├── .dockerignore ├── llm-codegen ├── LLMCodegenDemo.gif ├── LLMCodegenLogo.png ├── tsconfig.json ├── core │ ├── llmClients │ │ ├── baseLLMClient.ts │ │ ├── deepSeekLLMClient.ts │ │ ├── openAILLMClient.ts │ │ ├── anthropicLLMClient.ts │ │ └── openRouterLLMClient.ts │ ├── constants.ts │ ├── logger.ts │ ├── prompts │ │ ├── troubleshooter.main.prompt │ │ ├── testsFixer.main.prompt │ │ └── developer.main.prompt │ ├── utils.ts │ └── agents │ │ ├── troubleshooter.ts │ │ ├── developer.ts │ │ ├── testsFixer.ts │ │ ├── orchestrator.ts │ │ └── base.ts ├── .env.sample ├── package.json └── main.ts ├── src ├── modules │ ├── index.ts │ ├── todos │ │ ├── types.ts │ │ ├── diConfig.ts │ │ ├── tests │ │ │ ├── serverControllers.spec.ts │ │ │ └── api.spec.ts │ │ ├── serverControllers.ts │ │ ├── getTodoById.service.ts │ │ ├── updateExpiredTodos.service.ts │ │ ├── removeTodo.service.ts │ │ ├── getAllTodos.service.ts │ │ ├── updateTodo.service.ts │ │ ├── addTodo.service.ts │ │ ├── getUserTodos.service.ts │ │ ├── repository.ts │ │ ├── routes.ts │ │ └── controllers.ts │ ├── users │ │ ├── diConfig.ts │ │ ├── types.ts │ │ ├── getUsers.service.ts │ │ ├── authUtils.ts │ │ ├── getUser.service.ts │ │ ├── logoutUser.service.ts │ │ ├── refreshToken.service.ts │ │ ├── registerAnonymousUser.service.ts │ │ ├── loginUser.service.ts │ │ ├── routes.ts │ │ ├── repository.ts │ │ ├── tests │ │ │ └── api.spec.ts │ │ └── controllers.ts │ ├── diConfig.ts │ └── apiRoutes.ts ├── common │ ├── useTransaction.ts │ ├── useRateLimiter.ts │ ├── constants.ts │ ├── baseRepository.ts │ ├── createRoutes.ts │ ├── types.ts │ ├── operation.ts │ ├── createServerController.ts │ ├── utils.ts │ └── createController.ts ├── infra │ ├── loaders │ │ ├── db.ts │ │ ├── redis.ts │ │ ├── cronjobs.ts │ │ ├── index.ts │ │ ├── checkenvs.ts │ │ ├── logger.ts │ │ ├── diContainer.ts │ │ └── express.ts │ ├── middlewares │ │ ├── index.ts │ │ ├── checkRole.ts │ │ ├── attachCurrentUser.ts │ │ └── isAuth.ts │ ├── integrations │ │ ├── notification.service.ts │ │ ├── memoryStorage.service.ts │ │ └── aws.service.ts │ └── data │ │ ├── migrations │ │ └── 20200426153712_users_todos.ts │ │ └── seeds │ │ └── init.ts ├── tests │ ├── utils.ts │ └── test-setup.ts ├── app.ts ├── server.ts └── config │ ├── knexfile.ts │ └── app.ts ├── .env.sample ├── Dockerfile.dev ├── Dockerfile.test ├── .gitignore ├── entrypoint.test.sh ├── entrypoint.dev.sh ├── jest.config.js ├── docker-compose.test.yml ├── docker-compose.dev.yml ├── typedoc.json ├── LICENSE.md ├── package.json ├── tsconfig.json └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .* 4 | !.env.sample 5 | llm-codegen -------------------------------------------------------------------------------- /llm-codegen/LLMCodegenDemo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vyancharuk/nodejs-api-boilerplate/HEAD/llm-codegen/LLMCodegenDemo.gif -------------------------------------------------------------------------------- /llm-codegen/LLMCodegenLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vyancharuk/nodejs-api-boilerplate/HEAD/llm-codegen/LLMCodegenLogo.png -------------------------------------------------------------------------------- /src/modules/index.ts: -------------------------------------------------------------------------------- 1 | export { default as routes } from './apiRoutes'; 2 | export { default as initDiContainer } from './diConfig'; 3 | -------------------------------------------------------------------------------- /src/modules/todos/types.ts: -------------------------------------------------------------------------------- 1 | interface Todo { 2 | id: string; 3 | content: string; 4 | userId: string; 5 | } 6 | export { Todo }; 7 | -------------------------------------------------------------------------------- /src/common/useTransaction.ts: -------------------------------------------------------------------------------- 1 | export default function useTransaction() { 2 | return target => { 3 | target.useTransaction = true; 4 | }; 5 | } 6 | -------------------------------------------------------------------------------- /src/infra/loaders/db.ts: -------------------------------------------------------------------------------- 1 | import Knex from 'knex'; 2 | import configOptions from '../../config/knexfile'; 3 | 4 | export default Knex(configOptions); 5 | -------------------------------------------------------------------------------- /src/infra/loaders/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis'; 2 | import appConfig from '../../config/app'; 3 | 4 | export default new Redis(appConfig.redisUri, { keyPrefix: 'todo_app_' }); 5 | -------------------------------------------------------------------------------- /src/infra/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | import attachCurrentUser from './attachCurrentUser'; 2 | import isAuth from './isAuth'; 3 | import checkRole from './checkRole'; 4 | 5 | export { attachCurrentUser, isAuth, checkRole }; 6 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | NODE_ENV= 2 | DB_URI= 3 | JWT_SECRET= 4 | REDIS_URI= 5 | PORT= 6 | # AWS_BUCKET=? - optional env var 7 | # AWS_ACCESS_KEY=? - optional env var 8 | # AWS_SECRET=? - optional env var 9 | # AWS_REGION=? - optional env var -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY . . 6 | 7 | RUN npm cache clean --force --loglevel=error && npm install 8 | 9 | EXPOSE 8080 10 | 11 | CMD ["/bin/bash","-c","chmod +x ./entrypoint.dev.sh && ./entrypoint.dev.sh"] 12 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM node:18 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY . . 6 | 7 | # npm install is not needed because it is supposed to run locally 8 | # RUN npm install 9 | 10 | CMD ["/bin/bash","-c","chmod +x ./entrypoint.test.sh && ./entrypoint.test.sh"] 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | 4 | .env* 5 | !.env.sample 6 | gcconfig.json 7 | src/examples 8 | samples 9 | docs 10 | llm-codegen/node_modules 11 | # Elastic Beanstalk Files 12 | .elasticbeanstalk/* 13 | !.elasticbeanstalk/*.cfg.yml 14 | !.elasticbeanstalk/*.global.yml 15 | -------------------------------------------------------------------------------- /src/common/useRateLimiter.ts: -------------------------------------------------------------------------------- 1 | export default function useRateLimiter(id, params) { 2 | return (target) => { 3 | if (!target.rateLimiters) { 4 | target.rateLimiters = []; 5 | } 6 | target.rateLimiters.push({ 7 | keyPrefix: id, 8 | ...params, 9 | }); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/modules/todos/diConfig.ts: -------------------------------------------------------------------------------- 1 | import { BINDINGS } from '../../common/constants'; 2 | import { Container } from '../../common/types'; 3 | 4 | import { TodosRepository } from './repository'; 5 | 6 | export default (container: Container) => { 7 | container.bind(BINDINGS.TodosRepository).to(TodosRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /src/modules/users/diConfig.ts: -------------------------------------------------------------------------------- 1 | import { BINDINGS } from '../../common/constants'; 2 | import { Container } from '../../common/types'; 3 | 4 | import { UsersRepository } from './repository'; 5 | 6 | export default (container: Container) => { 7 | container.bind(BINDINGS.UsersRepository).to(UsersRepository); 8 | }; 9 | -------------------------------------------------------------------------------- /llm-codegen/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "dist", 6 | "esModuleInterop": true, 7 | "strict": true 8 | }, 9 | "include": ["./*.ts", "core/constants.ts", "core/logger.ts", "core/utils.ts"], 10 | "exclude": ["node_modules", "**/*.spec.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /src/infra/integrations/notification.service.ts: -------------------------------------------------------------------------------- 1 | import { NotifData } from '../../common/types'; 2 | 3 | // TODO: provide proper email notification implementation 4 | export default class Notifications { 5 | public async sendEmail(notifData: NotifData): Promise {} 6 | public async sendPushNotification(notifData: NotifData): Promise {} 7 | } 8 | -------------------------------------------------------------------------------- /src/tests/utils.ts: -------------------------------------------------------------------------------- 1 | const addHeaders = async (request: any, jwt?: string): Promise => { 2 | request 3 | .set('Content-Type', 'application/json') 4 | .set('Accept', 'application/json'); 5 | 6 | if (jwt) { 7 | request.set('Authorization', `Bearer ${jwt}`); 8 | } 9 | 10 | return request; 11 | }; 12 | 13 | export { addHeaders }; 14 | -------------------------------------------------------------------------------- /src/modules/todos/tests/serverControllers.spec.ts: -------------------------------------------------------------------------------- 1 | import { todoServerController } from '../serverControllers'; 2 | 3 | describe('Todos Server', () => { 4 | 5 | it('Should correctly update expired todos', async () => { 6 | 7 | const result = await todoServerController.updateExpiredTodos({}); 8 | 9 | expect(result).not.toEqual([]); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import express, { Application } from 'express'; 2 | import loaders from './infra/loaders'; 3 | import logger from './infra/loaders/logger'; 4 | 5 | const app: Application = express(); 6 | 7 | async function initLoaders() { 8 | await loaders.init({ expressApp: app }); 9 | 10 | logger.info('After loaders initialized'); 11 | } 12 | app['initLoaders'] = initLoaders; 13 | 14 | export default app; 15 | -------------------------------------------------------------------------------- /src/modules/diConfig.ts: -------------------------------------------------------------------------------- 1 | import { Container } from '../common/types'; 2 | import usersDiConfig from './users/diConfig'; 3 | import todosDiConfig from './todos/diConfig'; 4 | 5 | /** 6 | * 7 | * Configures dependency injection for all modules by initializing their respective DI configurations. 8 | */ 9 | export default function initializeModulesDI(container: Container) { 10 | usersDiConfig(container); 11 | todosDiConfig(container); 12 | }; 13 | -------------------------------------------------------------------------------- /llm-codegen/core/llmClients/baseLLMClient.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @abstract 3 | * @class BaseLLMClient 4 | * 5 | * The BaseLLMClient abstract class defines a standardized interface for interacting with 6 | * various LLM providers. 7 | */ 8 | export abstract class BaseLLMClient { 9 | abstract execute( 10 | prompt: string, 11 | maxTokens?: number, 12 | temperature?: number, 13 | retry?: number 14 | ): Promise<{ content: string; inputTokens?: number; outputTokens?: number }>; 15 | } 16 | -------------------------------------------------------------------------------- /entrypoint.test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # -------------------------------------------------- 4 | # 1) Runs migrations 5 | # 2) Seeds the database 6 | # 3) Runs tests with a custom heap memory limit 7 | # -------------------------------------------------- 8 | 9 | echo "==> Running DB migrations..." 10 | npm run migrate:latest 11 | 12 | echo "==> Seeding the DB..." 13 | env npm run seed 14 | 15 | echo "==> Running tests with increased heap size..." 16 | npm run test -- --max-old-space-size=1536 -------------------------------------------------------------------------------- /entrypoint.dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # -------------------------------------------------- 4 | # 1) Runs migrations 5 | # 2) Seeds the database (skipping if already run) 6 | # 3) Starts the development server 7 | # -------------------------------------------------- 8 | 9 | echo "==> Running DB migrations..." 10 | npm run migrate:latest 11 | 12 | echo "==> Seeding the DB (skipping if already run)..." 13 | env SKIP_IF_ALREADY_RUN=true npm run seed 14 | 15 | echo "==> Starting the development server..." 16 | npm run dev -------------------------------------------------------------------------------- /src/modules/apiRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | 3 | import { userRoutes } from './users/routes'; 4 | import { todoRoutes } from './todos/routes'; 5 | import { createRoutes } from '../common/createRoutes'; 6 | 7 | /** 8 | * 9 | * Configures and registers all API routes for the application, including user and todo routes. 10 | */ 11 | export default function initializeModuleRoutes() { 12 | const app = Router(); 13 | 14 | createRoutes(app, userRoutes); 15 | createRoutes(app, todoRoutes); 16 | 17 | return app; 18 | }; 19 | -------------------------------------------------------------------------------- /src/modules/users/types.ts: -------------------------------------------------------------------------------- 1 | // type UserInputData = { userName: string; email?: string; pwd: string }; 2 | // type UserDTO = { id: string; user_name: string; email?: string }; 3 | 4 | interface UserInputData { 5 | id?: string; 6 | userName: string; 7 | email?: string; 8 | password: string; 9 | role: string; 10 | refreshToken: string; 11 | } 12 | 13 | interface User { 14 | id: string; 15 | user_name: string; 16 | email?: string; 17 | role: string; 18 | refreshToken: string; 19 | } 20 | 21 | export { UserInputData, User }; 22 | -------------------------------------------------------------------------------- /src/infra/middlewares/checkRole.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Request, 3 | Response, 4 | NextFunction, 5 | CustomError 6 | } from '../../common/types'; 7 | 8 | /** 9 | * Check if current user's role match one of passed using bits logic 10 | * @param roleBits 11 | */ 12 | const checkRole = ( 13 | roleBits: number, 14 | errorMsg: string = "You don't have enough permissions" 15 | ) => (req: Request, res: Response, next: NextFunction) => { 16 | if ((req['currentUser'].role & roleBits) === 0) { 17 | throw new CustomError(401, 'UnauthorizedError', errorMsg); 18 | } 19 | next(); 20 | }; 21 | export default checkRole; 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | 2 | // make sure JWT_SECRET is not empty 3 | process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-jwt-secret-key'; 4 | 5 | module.exports = { 6 | roots: ['/src'], 7 | testMatch: [ 8 | '**/__tests__/**/*.+(ts|tsx|js)', 9 | '**/?(*.)+(spec|test).+(ts|tsx|js)', 10 | ], 11 | transform: { 12 | '^.+\\.(ts|tsx)$': 'ts-jest', 13 | }, 14 | setupFilesAfterEnv: ['/src/tests/test-setup.ts'], 15 | testPathIgnorePatterns: ['/node_modules/'], 16 | transformIgnorePatterns: [ 17 | '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|ts|tsx)$', 18 | ], 19 | testEnvironment: 'node' 20 | }; 21 | -------------------------------------------------------------------------------- /llm-codegen/.env.sample: -------------------------------------------------------------------------------- 1 | # You need to provide one of the provider’s API keys 2 | OPENAI_API_KEY=your_openai_api_key_here 3 | OPENAI_MODEL_ID=gpt-4.1-mini 4 | ANTHROPIC_API_KEY=your_anthropic_api_key_here 5 | ANTHROPIC_CLAUDE_MODEL_ID=claude-3-7-sonnet-20250219 6 | OPEN_ROUTER_API_KEY=your_open_router_api_key 7 | DEEP_SEEK_API_KEY=your_deep_seek_api_key 8 | # Optional env vars 9 | LOG_LEVEL=info/error 10 | MAX_REGENERATE_CODE_ATTEMPTS=count_of_attempts_to_regenerate_missing_code_files 11 | MAX_FIX_CODE_ATTEMPTS=count_of_attempts_to_regenerate_files_with_compilation_errors 12 | MAX_FIX_E2E_TESTS_ATTEMPTS=count_of_attempts_to_fix_e2e_tests 13 | -------------------------------------------------------------------------------- /src/infra/loaders/cronjobs.ts: -------------------------------------------------------------------------------- 1 | 2 | import cron from 'node-cron'; 3 | 4 | import appConfig from '../../config/app'; 5 | import { todoServerController } from '../../modules/todos/serverControllers'; 6 | import logger from './logger'; 7 | 8 | const initCronTasks = async () => { 9 | logger.info('cronjob:init:initCronTasks:env:', appConfig.env); 10 | 11 | // every 24 hours at 32:55 12 | cron.schedule('0 59 23 * * *', async () => { 13 | logger.info('cronjob:running:update:expired:todos'); 14 | 15 | await todoServerController.updateExpiredTodos({}); 16 | }); 17 | 18 | }; 19 | 20 | export { initCronTasks }; -------------------------------------------------------------------------------- /src/infra/loaders/index.ts: -------------------------------------------------------------------------------- 1 | import { Application } from '../../common/types'; 2 | import { initDI } from './diContainer'; 3 | import expressLoader from './express'; 4 | import checkEnvs from './checkenvs'; 5 | import { initCronTasks } from './cronjobs'; 6 | 7 | const { NODE_ENV } = process.env; 8 | 9 | const init = async ({ expressApp }: { expressApp: Application }) => { 10 | await initDI(); 11 | await expressLoader({ app: expressApp }); 12 | 13 | // skip env vars check for tests setup 14 | if (NODE_ENV !== 'test') { 15 | await checkEnvs(); 16 | } 17 | 18 | await initCronTasks(); 19 | }; 20 | 21 | export default { init }; 22 | -------------------------------------------------------------------------------- /src/modules/todos/serverControllers.ts: -------------------------------------------------------------------------------- 1 | import { createServerController } from '../../common/createServerController'; 2 | import { UpdateExpiredTodos } from './updateExpiredTodos.service'; 3 | 4 | /** 5 | * @module TodosServerController 6 | * 7 | * Server Controller for handling server based operations for todos 8 | */ 9 | export const todoServerController = { 10 | /** 11 | * Updates the expired todos 12 | * 13 | * @param {Any} params - The parameters that can be passed to service 14 | */ 15 | updateExpiredTodos: createServerController(UpdateExpiredTodos, (_params: any) => ({ 16 | callerId: 'cron', 17 | })), 18 | }; 19 | -------------------------------------------------------------------------------- /src/infra/loaders/checkenvs.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import dotenv from 'dotenv'; 4 | import logger from '../loaders/logger'; 5 | 6 | export default () => { 7 | const config = dotenv.parse( 8 | fs.readFileSync(path.resolve(process.cwd(), '.env.sample')) 9 | ); 10 | 11 | Object.keys(config).forEach(envName => { 12 | const isOptional = config[envName].trim() === '?'; 13 | 14 | if (!isOptional && !process.env[envName]) { 15 | throw new Error(`Mandatory env var ${envName} is missing`); 16 | } else if (isOptional && !process.env[envName]) { 17 | logger.warn(`Optional env var ${envName} is missing`); 18 | } 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /src/infra/middlewares/attachCurrentUser.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from '../../common/types'; 2 | import { getRoleCode } from '../../common/utils'; 3 | 4 | /** 5 | * Attach user to req.user 6 | * @param {*} req Express req Object 7 | * @param {*} res Express res Object 8 | * @param {*} next Express next Function 9 | */ 10 | const attachCurrentUser = async ( 11 | req: Request, 12 | res: Response, 13 | next: NextFunction 14 | ) => { 15 | const currentUser = { 16 | id: req['token'].id, 17 | userName: req['token'].name, 18 | role: getRoleCode(req['token'].role), 19 | }; 20 | req['currentUser'] = currentUser; 21 | return next(); 22 | }; 23 | 24 | export default attachCurrentUser; 25 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | td-app-test: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile.test 8 | 9 | working_dir: /app 10 | volumes: 11 | - ./:/app 12 | 13 | tty: true 14 | 15 | environment: 16 | DB_URI: postgresql://postgres:postgres@td-db-test:7432/td_test 17 | REDIS_URI: redis://@redis-test:8379 18 | 19 | env_file: 20 | - .env 21 | 22 | depends_on: 23 | - td-db-test 24 | 25 | td-db-test: 26 | image: postgres:latest 27 | restart: always 28 | command: -p 7432 -c fsync=off 29 | ports: 30 | - 7432:7432 31 | 32 | tmpfs: 33 | - /var/lib/postgresql/data 34 | environment: 35 | POSTGRES_USER: postgres 36 | POSTGRES_PASSWORD: postgres 37 | POSTGRES_DB: td_test 38 | -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | const BINDINGS = { 2 | KnexConnection: Symbol.for('KnexConnection'), 3 | DbAccess: Symbol.for('DbAccess'), 4 | Redis: Symbol.for('Redis'), 5 | BaseRepository: Symbol.for('BaseRepository'), 6 | AWSService: Symbol.for('AWSService'), 7 | MemoryStorage: Symbol.for('MemoryStorage'), 8 | 9 | // auth 10 | LoginUser: Symbol.for('LoginUser'), 11 | RegisterUser: Symbol.for('RegisterUser'), 12 | RefreshToken: Symbol.for('RefreshToken'), 13 | 14 | // users 15 | UsersRepository: Symbol.for('UsersRepository'), 16 | GetUser: Symbol.for('GetUser'), 17 | GetUsers: Symbol.for('GetUsers'), 18 | 19 | // todos 20 | TodosRepository: Symbol.for('TodosRepository'), 21 | GetTodos: Symbol.for('GetTodos'), 22 | GetUserTodos: Symbol.for('GetUserTodos'), 23 | } 24 | 25 | export { BINDINGS }; 26 | -------------------------------------------------------------------------------- /src/infra/integrations/memoryStorage.service.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from '../../common/types'; 2 | import { BINDINGS } from '../../common/constants'; 3 | import appConfig from '../../config/app'; 4 | 5 | // const DEFAULT_EXPIRATION = 1000 * 60 * 60; // 1 hour 6 | const DEFAULT_EXPIRATION = appConfig.jwtDuration * 1000; 7 | 8 | @injectable() 9 | class MemoryStorage { 10 | @inject(BINDINGS.Redis) 11 | private _redis!: any; 12 | 13 | async setValue( 14 | key: string, 15 | value: any, 16 | expiresIn: number = DEFAULT_EXPIRATION 17 | ) { 18 | // PX means milliseconds https://redis.io/commands/set 19 | return this._redis.set(key, value, 'PX', expiresIn); 20 | } 21 | 22 | async getValue(key: string) { 23 | return this._redis.get(key); 24 | } 25 | 26 | async delValue(key: string) { 27 | return this._redis.del(key); 28 | } 29 | } 30 | 31 | export default MemoryStorage; 32 | -------------------------------------------------------------------------------- /llm-codegen/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "llm-codegen", 3 | "version": "1.0.0", 4 | "description": "LLM code generation tool for Node.js Typescript boilerplate", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "ts-node main.ts" 8 | }, 9 | "keywords": [ 10 | "nodejs", 11 | "typescript", 12 | "boilerplate", 13 | "codegen", 14 | "LLM", 15 | "openai", 16 | "claude", 17 | "deepseek" 18 | ], 19 | "author": "v.yancharuk@gmail.com", 20 | "license": "MIT", 21 | "dependencies": { 22 | "@anthropic-ai/sdk": "^0.32.1", 23 | "dotenv": "^16.4.5", 24 | "openai": "^4.68.0", 25 | "ora": "^5.4.1", 26 | "pluralize": "^8.0.0", 27 | "readline-sync": "^1.4.10", 28 | "winston": "^3.13.0", 29 | "yargs": "^17.7.2" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^22.7.6", 33 | "@types/readline-sync": "^1.4.8", 34 | "@types/winston": "^2.4.4", 35 | "@types/yargs": "^17.0.33", 36 | "ts-node": "^10.9.2", 37 | "typescript": "^5.6.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | td-app: 5 | 6 | build: 7 | context: . 8 | dockerfile: Dockerfile.dev 9 | 10 | working_dir: /app 11 | volumes: 12 | - ./:/app 13 | 14 | tty: true 15 | 16 | ports: 17 | - '8080:8080' 18 | environment: 19 | DB_URI: postgresql://postgres:postgres@td-db:6432/todo_db 20 | REDIS_URI: redis://@td-redis:7379 21 | PORT: 8080 22 | 23 | env_file: 24 | - .env 25 | 26 | depends_on: 27 | - td-db 28 | - td-redis 29 | 30 | td-db: 31 | 32 | image: postgres:latest 33 | restart: always 34 | command: -p 6432 35 | ports: 36 | - 6432:6432 37 | environment: 38 | POSTGRES_USER: postgres 39 | POSTGRES_PASSWORD: postgres 40 | POSTGRES_DB: todo_db 41 | 42 | td-redis: 43 | 44 | image: redis:latest 45 | ports: 46 | - '7379:7379' 47 | volumes: 48 | - redis-data:/data 49 | entrypoint: redis-server --appendonly yes --port 7379 50 | restart: always 51 | 52 | volumes: 53 | redis-data: 54 | -------------------------------------------------------------------------------- /src/infra/middlewares/isAuth.ts: -------------------------------------------------------------------------------- 1 | import { expressjwt as jwt } from 'express-jwt'; 2 | import config from '../../config/app'; 3 | import { Request } from '../../common/types'; 4 | 5 | /** 6 | * We are assuming that the JWT will come in a header with the form 7 | * 8 | * Authorization: Bearer ${JWT} 9 | */ 10 | const getTokenFromHeader = (req: Request) => { 11 | /** 12 | * @TODO Edge and Internet Explorer do some weird things with the headers 13 | */ 14 | if ( 15 | (req.headers.authorization && 16 | req.headers.authorization.split(' ')[0] === 'Token') || 17 | (req.headers.authorization && 18 | req.headers.authorization.split(' ')[0] === 'Bearer') 19 | ) { 20 | return req.headers.authorization.split(' ')[1]; 21 | } 22 | return undefined; 23 | }; 24 | 25 | const isAuth = jwt({ 26 | secret: config.jwtSecret, // The _secret_ to sign the JWTs 27 | requestProperty: 'token', // Use req.token to store the JWT 28 | algorithms: ["HS256"], 29 | getToken: getTokenFromHeader, // How to extract the JWT from the request 30 | }); 31 | 32 | export default isAuth; 33 | -------------------------------------------------------------------------------- /llm-codegen/core/constants.ts: -------------------------------------------------------------------------------- 1 | export const EXAMPLE_MODULE_FILE_PATHS = { 2 | REPOSITORY: 'src/modules/todos/repository.ts', 3 | CONTROLLERS: 'src/modules/todos/controllers.ts', 4 | GET_USER_TODOS_SERVICE: 'src/modules/todos/getUserTodos.service.ts', 5 | GET_TODO_BY_ID_SERVICE:'src/modules/todos/getTodoById.service.ts', 6 | ADD_TODO_SERVICE: 'src/modules/todos/addTodo.service.ts', 7 | UPDATE_TODO_SERVICE: 'src/modules/todos/updateTodo.service.ts', 8 | REMOVE_TODO_SERVICE: 'src/modules/todos/removeTodo.service.ts', 9 | ROUTES: 'src/modules/todos/routes.ts', 10 | TYPES: 'src/modules/todos/types.ts', 11 | DI_CONFIG: 'src/modules/todos/diConfig.ts', 12 | E2E_TESTS: 'src/modules/todos/tests/api.spec.ts', 13 | MIGRATION: 'src/infra/data/migrations/20200426153712_users_todos.ts', 14 | ALL_API_ROUTES: 'src/modules/apiRoutes.ts', 15 | ALL_CONSTANTS: 'src/common/constants.ts', 16 | ALL_DI_CONFIG: 'src/modules/diConfig.ts', 17 | ALL_SEEDS: 'src/infra/data/seeds/init.ts' 18 | } 19 | 20 | export const DEFAULT_MAX_TOKENS = 8000; 21 | export const DEFAULT_TEMPERATURE = 0.4; -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "entryPoints": [ 3 | "src/modules/**/routes.ts", 4 | "src/modules/**/controllers.ts", 5 | "src/modules/**/*.service.ts", 6 | "src/modules/**/repository.ts", 7 | "src/modules/**/types.ts", 8 | "src/modules/apiRoutes.ts", 9 | "src/modules/diConfig.ts", 10 | "src/common/createController.ts", 11 | "src/common/createRoutes.ts", 12 | "src/common/operation.ts", 13 | "src/common/baseRepository.ts" 14 | ], 15 | "out": "docs", 16 | "includeVersion": true, 17 | "exclude": [ 18 | "**/node_modules/**", 19 | "**/tests/**", 20 | "**/*.spec.ts", 21 | "**/*.test.ts" 22 | ], 23 | "excludePrivate": false, 24 | "excludeProtected": false, 25 | "excludeInternal": false, 26 | "excludeNotDocumented": false, 27 | "tsconfig": "tsconfig.json", 28 | "readme": "README.md", 29 | "name": "Todo API Documentation", 30 | "hideGenerator": true, 31 | "entryPointStrategy": "expand", 32 | "visibilityFilters": { 33 | "protected": true, 34 | "private": true, 35 | "inherited": true, 36 | "external": true 37 | }, 38 | "treatWarningsAsErrors": false 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 Vladimir Yancharuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/modules/todos/getTodoById.service.ts: -------------------------------------------------------------------------------- 1 | import { CustomError, inject, injectable, z } from '../../common/types'; 2 | import Operation from '../../common/operation'; 3 | import logger from '../../infra/loaders/logger'; 4 | import { BINDINGS } from '../../common/constants'; 5 | import { Todo } from './types'; 6 | import { HTTP_STATUS } from '../../common/types'; 7 | 8 | @injectable() 9 | export class GetTodoById extends Operation { 10 | static validationRules = z.object({ 11 | todoId: z.string().uuid().min(1), 12 | }); 13 | 14 | @inject(BINDINGS.TodosRepository) 15 | private _todosRepository: any; 16 | 17 | async execute(validatedData: any): Promise { 18 | const { todoId } = validatedData; 19 | 20 | try { 21 | logger.info(`GetTodoById:execute:todoId=${todoId}`); 22 | const todo = await this._todosRepository.findById(todoId); 23 | if (!todo) { 24 | // use CustomError to pass HTTP status 25 | throw new CustomError(HTTP_STATUS.NOT_FOUND, 'NotFoundError', 'Todo not found'); 26 | } 27 | return todo; 28 | } catch (error) { 29 | logger.error('GetTodoById:error', error); 30 | throw error; 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/infra/loaders/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import rTracer from 'cls-rtracer'; 3 | import config from '../../config/app'; 4 | 5 | const transports: winston.transports.ConsoleTransportInstance[] = []; 6 | 7 | const customFormat = winston.format.printf(({ level, message, timestamp }) => { 8 | const traceId = rTracer?.id?.(); 9 | return `${level} ${timestamp}${traceId ? ` [${traceId}]` : ''} ${message}`; 10 | }); 11 | 12 | transports.push( 13 | new winston.transports.Console({ 14 | level: config.env === 'test' ? 'error' : 'info', 15 | handleExceptions: true, 16 | format: winston.format.combine( 17 | winston.format.colorize(), 18 | winston.format.timestamp({ format: 'MM/DD HH:mm:ss:SSS' }), 19 | customFormat 20 | ), 21 | }) 22 | ); 23 | 24 | const loggerInstance = winston.createLogger({ 25 | level: config.logs.level, 26 | levels: winston.config.npm.levels, 27 | format: winston.format.combine( 28 | winston.format.timestamp({ 29 | format: 'YYYY-MM-DD HH:mm:ss', 30 | }), 31 | winston.format.errors({ stack: true }), 32 | winston.format.splat(), 33 | winston.format.json() 34 | ), 35 | transports, 36 | }); 37 | 38 | export default loggerInstance; 39 | -------------------------------------------------------------------------------- /src/modules/users/getUsers.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable, z } from '../../common/types'; 2 | 3 | import Operation from '../../common/operation'; 4 | import logger from '../../infra/loaders/logger'; 5 | 6 | import { BINDINGS } from '../../common/constants'; 7 | 8 | /** 9 | * @class GetUsers 10 | * 11 | * Handles the operation of retrieving all available users. 12 | */ 13 | @injectable() 14 | export class GetUsers extends Operation { 15 | static validationRules = z.object({ 16 | userId: z.string().uuid(), // validate UUID 17 | }); 18 | 19 | @inject(BINDINGS.UsersRepository) 20 | private _usersRepository: any; 21 | 22 | async execute(validatedUserData) { 23 | const { userId } = validatedUserData; 24 | try { 25 | logger.info(`GetUsers:execute:userId=${userId}`); 26 | 27 | return this._usersRepository.findAll(); 28 | } catch (error) { 29 | logger.error('GetUsers:error', error); 30 | 31 | if (typeof error === 'string') { 32 | throw new Error(error); 33 | } else if (error instanceof Error) { 34 | throw new Error(error.message); 35 | } else { 36 | throw new Error('An unexpected error occurred'); 37 | } 38 | } 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/modules/users/authUtils.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { nanoid } from 'nanoid'; 3 | // import argon2 from 'argon2'; 4 | import crypto from 'crypto'; 5 | import appConfig from '../../config/app'; 6 | import { User } from './types'; 7 | 8 | const generateJWT = ( 9 | user: User, 10 | clientId: string = appConfig.defaultClientId 11 | ) => { 12 | // TODO: switch to assymetric alg which uses RSA encryption 13 | // by default it uses symmetric alg HMAC 14 | return jwt.sign( 15 | { 16 | id: user.id, // We are gonna use this in the middleware 'isAuth' 17 | name: user.user_name, 18 | role: user.role, 19 | exp: Math.floor(Date.now() / 1000) + appConfig.jwtDuration, 20 | roles: user.role, 21 | }, 22 | appConfig.jwtSecret, 23 | { audience: clientId } 24 | ); 25 | }; 26 | 27 | const generateRefreshToken = () => { 28 | const refreshToken = nanoid(); 29 | 30 | return refreshToken; 31 | }; 32 | 33 | const hashPassword = (password: string) => { 34 | return ( 35 | crypto 36 | .createHash('md5') 37 | // .update(text, 'utf-8') 38 | .update(password) 39 | .digest('hex') 40 | ); 41 | }; 42 | export { generateJWT, generateRefreshToken, hashPassword }; 43 | -------------------------------------------------------------------------------- /src/modules/todos/updateExpiredTodos.service.ts: -------------------------------------------------------------------------------- 1 | import { BINDINGS } from '../../common/constants'; 2 | import Operation from '../../common/operation'; 3 | import { inject, injectable, z } from '../../common/types'; 4 | import useTransaction from '../../common/useTransaction'; 5 | import { stringifyError } from '../../common/utils'; 6 | import logger from '../../infra/loaders/logger'; 7 | import { Todo } from './types'; 8 | 9 | /** 10 | * @class UpdateExpiredTodo 11 | * 12 | * Implements setting expired todos 13 | */ 14 | @useTransaction() 15 | @injectable() 16 | export class UpdateExpiredTodos extends Operation { 17 | static validationRules = z.object({ 18 | callerId: z.enum(['cron', 'worker']), 19 | }); 20 | 21 | @inject(BINDINGS.TodosRepository) 22 | private _todosRepository: any; 23 | 24 | async execute(this: UpdateExpiredTodos, validatedUserData: any): Promise { 25 | const { callerId } = validatedUserData; 26 | 27 | try { 28 | logger.info(`UpdateExpiredTodo:callerId=${callerId}`); 29 | 30 | return this._todosRepository.setExpiredTodos(); 31 | } catch (error) { 32 | logger.error( 33 | `UpdateExpiredTodo:error:${stringifyError(error)}` 34 | ); 35 | throw error; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/common/baseRepository.ts: -------------------------------------------------------------------------------- 1 | import { Knex, injectable, inject } from './types'; 2 | import { BINDINGS } from './constants'; 3 | 4 | /** 5 | * @class BaseRepository 6 | * 7 | * Serves as the base repository class providing common database operations 8 | * such as setting database access and wrapping query builders with pagination and search functionalities. 9 | */ 10 | @injectable() 11 | class BaseRepository { 12 | @inject(BINDINGS.DbAccess) 13 | protected dbAccess!: Knex; 14 | 15 | setDbAccess(transaction: Knex) { 16 | this.dbAccess = transaction; 17 | } 18 | 19 | wrapWithPaginationAndSearch( 20 | qb: Knex.QueryBuilder, 21 | pageInd: number, 22 | pageSize: number, 23 | searchFields: { field: string; search: string }[], 24 | orderBy: string = 'created_at' 25 | ) { 26 | qb.offset(pageSize * pageInd).limit(pageSize); 27 | 28 | if (orderBy) qb.orderBy(orderBy, 'desc'); 29 | 30 | if (searchFields.length > 0) { 31 | qb.where(function () { 32 | searchFields.forEach(({ field, search }) => { 33 | if (search !== '') { 34 | this.orWhere(field, 'ilike', `%${search}%`); 35 | } 36 | }); 37 | }); 38 | } 39 | 40 | return qb; 41 | } 42 | } 43 | 44 | export default BaseRepository; 45 | -------------------------------------------------------------------------------- /llm-codegen/main.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import readlineSync from 'readline-sync'; 4 | import yargs from 'yargs'; 5 | 6 | import { Orchestrator } from './core/agents/Orchestrator'; 7 | import logger from './core/logger'; 8 | 9 | const pluralize = require('pluralize'); 10 | 11 | interface Args { 12 | description?: string; 13 | name?: string; 14 | } 15 | 16 | (async () => { 17 | const argv: Args = yargs.argv as Args; 18 | 19 | // ask user for a description and module name 20 | let projectDescription; 21 | 22 | if (argv.description) { 23 | projectDescription = argv.description; 24 | } else { 25 | projectDescription = readlineSync.question( 26 | "Enter the module description: " 27 | ); 28 | } 29 | 30 | let moduleName; 31 | 32 | if (argv.name) { 33 | moduleName = argv.name; 34 | } else { 35 | moduleName = readlineSync.question("Enter the module name: "); 36 | } 37 | 38 | if (!moduleName) { 39 | logger.warn("Entered:EMPTY:moduleName"); 40 | return; 41 | } 42 | 43 | if (!pluralize.isPlural(moduleName)) { 44 | moduleName = pluralize.plural(moduleName); 45 | } 46 | 47 | const orchestrator = new Orchestrator(projectDescription, moduleName); 48 | await orchestrator.execute(projectDescription, moduleName); 49 | })(); 50 | -------------------------------------------------------------------------------- /src/modules/todos/removeTodo.service.ts: -------------------------------------------------------------------------------- 1 | import { BINDINGS } from '../../common/constants'; 2 | import Operation from '../../common/operation'; 3 | import { inject, injectable, z } from '../../common/types'; 4 | import useTransaction from '../../common/useTransaction'; 5 | import logger from '../../infra/loaders/logger'; 6 | import { Todo } from './types'; 7 | 8 | /** 9 | * @class RemoveTodo 10 | * 11 | * Implements user todo removal 12 | */ 13 | @useTransaction() 14 | @injectable() 15 | export class RemoveTodo extends Operation { 16 | static validationRules = z.object({ 17 | userId: z.string().uuid().min(1), // Validates as a required UUID string 18 | todoId: z.string().uuid().min(1), // Validates as a required UUID string 19 | }); 20 | 21 | @inject(BINDINGS.TodosRepository) 22 | private _todosRepository: any; 23 | 24 | async execute(this: RemoveTodo, validatedUserData: any): Promise { 25 | const { userId, todoId } = validatedUserData; 26 | 27 | try { 28 | logger.info(`RemoveTodo:todoId=${todoId}`); 29 | 30 | return this._todosRepository.removeTodo(todoId, userId); 31 | } catch (error) { 32 | logger.error( 33 | `RemoveTodo:error:${(error as Error).name}:${(error as Error).message}` 34 | ); 35 | throw error; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/infra/integrations/aws.service.ts: -------------------------------------------------------------------------------- 1 | import S3 from 'aws-sdk/clients/s3'; 2 | import appConfig from '../../config/app'; 3 | import logger from '../../infra/loaders/logger'; 4 | 5 | class AWSService { 6 | private _bucket: S3; 7 | 8 | constructor() { 9 | this._bucket = new S3({ 10 | apiVersion: '2006-03-01', 11 | accessKeyId: appConfig.awsAccessKey, 12 | secretAccessKey: appConfig.awsSecretAccessKey, 13 | region: appConfig.awsRegion, 14 | }); 15 | } 16 | 17 | async saveToS3(key, fileStream, contentType = 'image/png') { 18 | if (!key || !fileStream) { 19 | throw new Error('folder and filePath are required'); 20 | } 21 | logger.info('AWSService:saveToS3:key=', key); 22 | return new Promise((resolve, reject) => { 23 | const params = { 24 | Bucket: appConfig.awsBucket, 25 | Key: key, 26 | Body: fileStream, 27 | ACL: 'public-read', 28 | ContentType: contentType, 29 | }; 30 | 31 | this._bucket.upload(params, function (err, data) { 32 | if (err) { 33 | logger.info('AWSService:saveToS3:error uploading your file: ', err); 34 | return reject(err); 35 | } 36 | logger.info('AWSService:saveToS3:successfully uploaded file', data.Location); 37 | 38 | return resolve(data.Location); 39 | }); 40 | }); 41 | } 42 | } 43 | 44 | export default new AWSService(); 45 | -------------------------------------------------------------------------------- /src/common/createRoutes.ts: -------------------------------------------------------------------------------- 1 | import { Router, RouteConfig } from './types'; 2 | 3 | /** 4 | * Creates and registers routes with the Express application based on the provided configuration. 5 | * 6 | * @param {Router} app - The main Express application instance. 7 | * @param {Record} routes - An object mapping route identifiers to their configurations. 8 | * 9 | * @example 10 | * ```typescript 11 | * const app = express(); 12 | * const routes = { 13 | * getUser: { 14 | * method: 'GET', 15 | * path: '/user/:id', 16 | * middlewares: [authenticate], 17 | * handler: getUserHandler, 18 | * }, 19 | * createUser: { 20 | * method: 'POST', 21 | * path: '/user', 22 | * middlewares: [validateUserData], 23 | * handler: createUserHandler, 24 | * }, 25 | * }; 26 | * 27 | * createRoutes(app, routes); 28 | * ``` 29 | */ 30 | export const createRoutes = (app: Router, routes: Record) => { 31 | const router = Router(); 32 | 33 | /** 34 | * Iterates over each route in the routes config object and registers it with the Express router. 35 | */ 36 | Object.values(routes).forEach((route) => { 37 | // Dynamically register the route based on the HTTP method 38 | (router as any)[route.method.toLowerCase()]( 39 | route.path, 40 | ...(route.middlewares || []), 41 | route.handler 42 | ); 43 | }); 44 | 45 | // Integrate the configured router into the main application 46 | app.use(router); 47 | }; -------------------------------------------------------------------------------- /src/modules/users/getUser.service.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { inject, injectable, toCamelCase, z } from '../../common/types'; 4 | 5 | import Operation from '../../common/operation'; 6 | import logger from '../../infra/loaders/logger'; 7 | import { BINDINGS } from '../../common/constants'; 8 | 9 | /** 10 | * @class GetUser 11 | * 12 | * Handles the operation of retrieving a single user details by ID or email. 13 | */ 14 | @injectable() 15 | export class GetUser extends Operation { 16 | static validationRules = z.object({ 17 | userId: z.string().uuid(), // validate UUID 18 | email: z.string().min(3).optional(), // string with minimum length of 3 19 | }); 20 | 21 | @inject(BINDINGS.UsersRepository) 22 | private _usersRepository: any; 23 | 24 | async execute(this: GetUser, validatedUserData: any) { 25 | const { userId, email } = validatedUserData; 26 | 27 | try { 28 | logger.info(`GetUser:execute:userId=${userId}:email=${email}`); 29 | let user; 30 | if (email) { 31 | user = await this._usersRepository.findById(email); 32 | } else { 33 | user = await this._usersRepository.findById(userId); 34 | } 35 | 36 | return _.omit(toCamelCase(user), ['password']); 37 | } catch (error) { 38 | logger.error('GetUser:error', error); 39 | 40 | if (typeof error === 'string') { 41 | throw new Error(error); 42 | } else if (error instanceof Error) { 43 | throw new Error(error.message); 44 | } else { 45 | throw new Error('An unexpected error occurred'); 46 | } 47 | } 48 | } 49 | } 50 | 51 | -------------------------------------------------------------------------------- /llm-codegen/core/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | 3 | const transports: winston.transports.ConsoleTransportInstance[] = []; 4 | 5 | const customFormat = winston.format.printf(({ level, message, timestamp }) => { 6 | return `${level} ${timestamp} ${message}`; 7 | }); 8 | 9 | transports.push( 10 | new winston.transports.Console({ 11 | level: process.env.LOG_LEVEL || 'info', 12 | handleExceptions: true, 13 | format: winston.format.combine( 14 | winston.format.colorize(), 15 | winston.format.timestamp({ format: 'MM/DD HH:mm:ss:SSS' }), 16 | customFormat 17 | ), 18 | }) 19 | ); 20 | 21 | const loggerInstance = winston.createLogger({ 22 | level: process.env.LOG_LEVEL || 'info', 23 | levels: winston.config.npm.levels, 24 | format: winston.format.combine( 25 | winston.format.timestamp({ 26 | format: 'YYYY-MM-DD HH:mm:ss', 27 | }), 28 | winston.format.errors({ stack: true }), 29 | winston.format.splat(), 30 | winston.format.json() 31 | ), 32 | transports, 33 | }); 34 | 35 | const concatArgs = (arr: any[]) => { 36 | return arr 37 | .map((arg: any) => { 38 | if (arg && typeof arg === 'object') { 39 | return JSON.stringify(arg, null, 4); 40 | } else { 41 | return arg; 42 | } 43 | }) 44 | .join(' '); 45 | }; 46 | 47 | export default { 48 | info: (...args: unknown[]) => { 49 | loggerInstance.info(concatArgs(args)); 50 | }, 51 | error: (...args: unknown[]) => { 52 | loggerInstance.error(concatArgs(args)); 53 | }, 54 | warn: (...args: unknown[]) => { 55 | loggerInstance.warn(concatArgs(args)); 56 | }, 57 | }; 58 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import app from './app'; 2 | import config from './config/app'; 3 | import knex from './infra/loaders/db'; 4 | import logger from './infra/loaders/logger'; 5 | 6 | (async () => { 7 | await app['initLoaders'](); 8 | const server = app.listen(config.port, () => { 9 | logger.info(`server:started:node=${config.appId}:is:ready:on:port=${config.port}`); 10 | }); 11 | 12 | 13 | const shutdown = (signal) => { 14 | logger.info('server:shutdown:node=', config.appId, 'signal=', signal); 15 | 16 | const start = Date.now(); 17 | server.close(function onServerClosed(err) { 18 | logger.info('server:shutdown:node=', config.appId, ':express:closed:duration=', Date.now() - start); 19 | if (err) { 20 | logger.error('server:shutdown:close:express:error=', err); 21 | process.exitCode = 1; 22 | } 23 | // release DB connection pull 24 | knex.destroy(); 25 | 26 | // exit 27 | process.exit(0); 28 | }); 29 | 30 | }; 31 | 32 | process.on('SIGTERM', function onSigterm() { 33 | logger.info('got:SIGTERM:graceful:shutdown:start', new Date().toISOString()) 34 | // start graceful shutdown here 35 | shutdown('SIGTERM') 36 | }); 37 | 38 | process.on('SIGINT', function onSigterm() { 39 | logger.info('got:SIGINT:graceful:shutdown:start', new Date().toISOString()) 40 | // start graceful shutdown here 41 | shutdown('SIGINT') 42 | }); 43 | 44 | process.on('SIGHUP', function onSigterm() { 45 | logger.info('got:SIGHUP:graceful:shutdown:start', new Date().toISOString()) 46 | // start graceful shutdown here 47 | shutdown('SIGHUP') 48 | }); 49 | })(); 50 | -------------------------------------------------------------------------------- /src/infra/data/migrations/20200426153712_users_todos.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from '../../../common/types'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.schema.createTable('users', (table) => { 5 | table.uuid('id').notNullable().primary(); 6 | 7 | table.string('user_name', 100).notNullable(); 8 | table.string('email', 50); 9 | // anonym when user do not provide any profile info (email etc.) 10 | table.enu('role', ['anonym', 'registered', 'admin']); 11 | table.string('password', 100); 12 | 13 | table.timestamps(true, true); 14 | }); 15 | 16 | await knex.schema.createTable('user_refresh_tokens', (table) => { 17 | table.uuid('id').notNullable().primary(); 18 | 19 | table.uuid('user_id').notNullable(); 20 | table.foreign('user_id').references('users.id'); 21 | 22 | table.string('client_id', 100).notNullable(); 23 | table.string('refresh_token', 200).notNullable(); 24 | 25 | table.timestamp('expires').notNullable(); 26 | table.timestamps(true, true); 27 | }); 28 | 29 | await knex.schema.createTable('todos', (table) => { 30 | table.uuid('id').notNullable().primary(); 31 | 32 | table.string('content', 1000).notNullable(); 33 | table.string('file_src', 500); 34 | table.uuid('user_id').notNullable(); 35 | table.foreign('user_id').references('users.id'); 36 | table.timestamp('expires_at'); 37 | table.boolean('expired'); 38 | 39 | table.timestamps(true, true); 40 | }); 41 | } 42 | 43 | export async function down(knex: Knex): Promise { 44 | await knex.schema.dropTable('todos'); 45 | await knex.schema.dropTable('user_refresh_tokens'); 46 | await knex.schema.dropTable('users'); 47 | } 48 | -------------------------------------------------------------------------------- /src/infra/loaders/diContainer.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { interfaces } from 'inversify'; 4 | import knex from './db'; 5 | import redis from './redis'; 6 | import awsService from '../integrations/aws.service'; 7 | import MemoryStorage from '../integrations/memoryStorage.service'; 8 | import { initDiContainer } from '../../modules'; 9 | import { BINDINGS } from '../../common/constants'; 10 | import { Knex, Container } from '../../common/types'; 11 | import BaseRepository from '../../common/baseRepository'; 12 | import logger from './logger'; 13 | 14 | /** 15 | * @module DIContainer 16 | * 17 | * Configures and initializes the Inversify dependency injection container, binding services, 18 | * repositories, and applying middleware for logging dependency resolutions. 19 | */ 20 | 21 | function diLogger(planAndResolve: interfaces.Next): interfaces.Next { 22 | return (args: interfaces.NextArgs) => { 23 | return planAndResolve(args); 24 | }; 25 | } 26 | 27 | const container: Container = new Container({ 28 | autoBindInjectable: true, 29 | }); 30 | 31 | container.bind(BINDINGS.DbAccess).toConstantValue(knex); 32 | container.bind(BINDINGS.Redis).toConstantValue(redis); 33 | container.bind(BINDINGS.BaseRepository).to(BaseRepository); 34 | container 35 | .bind(BINDINGS.AWSService) 36 | .toConstantValue(awsService); 37 | 38 | container 39 | .bind(BINDINGS.MemoryStorage) 40 | .toConstantValue(container.resolve(MemoryStorage)); 41 | 42 | container.applyMiddleware(diLogger); 43 | 44 | const initDI = () => { 45 | logger.info('init each module DI'); 46 | initDiContainer(container); 47 | }; 48 | 49 | export { container, initDI }; 50 | -------------------------------------------------------------------------------- /src/config/knexfile.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import appConfig from './app'; 3 | import logger from '../infra/loaders/logger'; 4 | 5 | type KnexConfig = { 6 | [key: string]: any; 7 | }; 8 | 9 | const useSqliteDb = process.env.USE_SQLITE_DB === 'true'; 10 | 11 | const config: KnexConfig = { 12 | client: useSqliteDb ? 'sqlite3' : 'pg', 13 | connection: useSqliteDb 14 | ? { 15 | filename: path.resolve(__dirname + '/../../') + '/test.sqlite3', 16 | } 17 | : appConfig.databaseURL, 18 | // sqlite specific settings 19 | ...(useSqliteDb 20 | ? { 21 | useNullAsDefault: true, 22 | } 23 | : {}), 24 | // skip knex debug output when necessary 25 | debug: process.env.DEBUG !== 'false', 26 | asyncStackTraces: true, 27 | pool: { 28 | min: 1, 29 | max: 4, 30 | acquireTimeoutMillis: 60000, 31 | idleTimeoutMillis: 600000, 32 | }, 33 | cwd: path.resolve(__dirname + '/../../'), 34 | migrations: { 35 | directory: path.resolve(__dirname + '/../infra/data/migrations'), 36 | tableName: 'knex_migrations', 37 | }, 38 | seeds: { 39 | directory: path.resolve(__dirname + '/../infra/data/seeds'), 40 | }, 41 | searchPath: ['knex', 'public'], 42 | log: { 43 | warn(message) { 44 | logger.warn(`knex:warn:${JSON.stringify(message, null, 4)}`); 45 | }, 46 | error(message) { 47 | logger.error(`knex:error:${JSON.stringify(message, null, 4)}`); 48 | }, 49 | deprecate(message) { 50 | logger.warn(`knex:deprecate:${JSON.stringify(message, null, 4)}`); 51 | }, 52 | debug(message) { 53 | logger.info(`knex:debug:${JSON.stringify(message, null, 4)}`); 54 | }, 55 | }, 56 | }; 57 | 58 | module.exports = config; 59 | 60 | export default config; 61 | -------------------------------------------------------------------------------- /src/modules/todos/getAllTodos.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable, z } from '../../common/types'; 2 | 3 | import Operation from '../../common/operation'; 4 | import logger from '../../infra/loaders/logger'; 5 | 6 | import { BINDINGS } from '../../common/constants'; 7 | import { Todo } from './types'; 8 | 9 | 10 | /** 11 | * @class GetAllTodos 12 | * 13 | * Service class to load all available todos 14 | */ 15 | @injectable() 16 | export class GetAllTodos extends Operation { 17 | static validationRules = z.object({ 18 | search: z 19 | .string() 20 | .max(50) 21 | .optional() 22 | .or(z.literal('').or(z.null())) 23 | .refine((val) => /^[a-zA-Z0-9]*$/.test(val || ''), { 24 | message: 'Must be alphanumeric', 25 | }), // Allows empty string, null, and only alphanumeric characters 26 | pageSize: z.number().int().min(1).max(100).optional(), // Integer between 1 and 100 27 | pageInd: z.number().int().min(0).max(10000).optional(), // Integer between 0 and 10000 28 | }); 29 | 30 | @inject(BINDINGS.TodosRepository) 31 | private _todosRepository; 32 | 33 | async execute(this: GetAllTodos, validatedUserData: any): Promise { 34 | const { pageSize = 100, pageInd = 0, search = '' } = validatedUserData; 35 | 36 | try { 37 | logger.info(`GetAllTodos:execute`); 38 | 39 | return this._todosRepository.findAll(pageInd, pageSize, search); 40 | } catch (error) { 41 | logger.error('GetAllTodos:error', error); 42 | if (typeof error === 'string') { 43 | throw new Error(error); 44 | } else if (error instanceof Error) { 45 | throw new Error(error.message); 46 | } else { 47 | throw new Error('An unexpected error occurred'); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/modules/todos/updateTodo.service.ts: -------------------------------------------------------------------------------- 1 | import { BINDINGS } from '../../common/constants'; 2 | import Operation from '../../common/operation'; 3 | import { inject, injectable, z } from '../../common/types'; 4 | import useTransaction from '../../common/useTransaction'; 5 | import { camelToSnake, snakeToCamel } from '../../common/utils'; 6 | import logger from '../../infra/loaders/logger'; 7 | import { Todo } from './types'; 8 | 9 | /** 10 | * @class UpdateTodo 11 | * 12 | * Implements updating todo properties 13 | */ 14 | @useTransaction() 15 | @injectable() 16 | export class UpdateTodo extends Operation { 17 | static validationRules = z.object({ 18 | userId: z.string().uuid().min(1), // Validates as a required UUID string 19 | todoId: z.string().uuid().min(1), // Validates as a required UUID string 20 | content: z.string().min(2).max(200), // String between 2 and 200 characters, required by default 21 | fileSrc: z.string().min(2).optional(), 22 | expiresAt: z.string().optional(), 23 | }); 24 | 25 | @inject(BINDINGS.TodosRepository) 26 | private _todosRepository: any; 27 | 28 | async execute(this: UpdateTodo, validatedUserData: any): Promise { 29 | const { userId, todoId, content, fileSrc, expiresAt } = validatedUserData; 30 | 31 | try { 32 | logger.info( 33 | `UpdateTodo:todoId=${todoId}:content=${content}:fileSrc=${fileSrc}:expiresAt=${expiresAt}` 34 | ); 35 | 36 | const updatedTodos = await this._todosRepository.updateTodo( 37 | todoId, 38 | userId, 39 | camelToSnake({ content, fileSrc, expiresAt }) 40 | ); 41 | 42 | return snakeToCamel(updatedTodos[0]); 43 | } catch (error) { 44 | logger.error( 45 | `UpdateTodo:error:${(error as Error).name}:${(error as Error).message}` 46 | ); 47 | throw error; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | import { Application, NextFunction, Request, Response, Router } from 'express'; 3 | import { Container, injectable, inject, interfaces } from 'inversify'; 4 | import toCamelCase from 'camelcase-keys'; 5 | import { z } from 'zod'; 6 | import Operation from './operation'; 7 | import { User } from '../modules/users/types'; 8 | import * as HTTP_STATUS from 'http-status'; 9 | 10 | enum UserRoles { 11 | Anonym = 1, 12 | Registered = 2, 13 | Admin = 4, 14 | } 15 | 16 | interface IdNameDTO { 17 | id: string; 18 | name: string; 19 | } 20 | 21 | type OperationResult = { 22 | result: string; 23 | error?: string; 24 | }; 25 | 26 | interface NotifData {} 27 | type MiddlewareFn = (req: Request, res: Response, next: NextFunction) => any; 28 | 29 | class CustomError extends Error { 30 | public status: number; 31 | 32 | constructor(status, name, message) { 33 | super(); 34 | this.name = name; 35 | this.message = message; 36 | this.status = status; 37 | 38 | if (Error.captureStackTrace) { 39 | Error.captureStackTrace(this, CustomError); 40 | } 41 | } 42 | } 43 | 44 | /** 45 | * Represents a configuration for an Express.js route. 46 | */ 47 | type RouteConfig = { 48 | method: 'GET' | 'POST' | 'PUT' | 'DELETE' | string; 49 | path: string; 50 | handler: (req: Request, res: Response, next: NextFunction) => void | Promise; 51 | middlewares?: MiddlewareFn[]; 52 | }; 53 | 54 | export { 55 | injectable, 56 | inject, 57 | Application, 58 | Request, 59 | Response, 60 | NextFunction, 61 | Router, 62 | z, 63 | Knex, 64 | Container, 65 | interfaces, 66 | User, 67 | IdNameDTO, 68 | MiddlewareFn, 69 | NotifData, 70 | Operation, 71 | toCamelCase, 72 | CustomError, 73 | UserRoles, 74 | OperationResult, 75 | HTTP_STATUS, 76 | RouteConfig, 77 | }; 78 | -------------------------------------------------------------------------------- /src/modules/users/logoutUser.service.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | import { inject, injectable, z } from '../../common/types'; 4 | import Operation from '../../common/operation'; 5 | import useRateLimiter from '../../common/useRateLimiter'; 6 | import { BINDINGS } from '../../common/constants'; 7 | import logger from '../../infra/loaders/logger'; 8 | 9 | /** 10 | * @class LogoutUser 11 | * 12 | * Handles the logout operation for a user by invalidating their JWT and removing associated refresh tokens. 13 | */ 14 | @useRateLimiter('LOGOUT_USER_PER_HOUR_BY_IP', { 15 | points: 5, // 5 calls 16 | duration: 60 * 60, // per 1 hour 17 | blockDuration: 60 * 60, // block on 1 hour 18 | }) 19 | @injectable() 20 | export class LogoutUser extends Operation { 21 | static validationRules = z.object({ 22 | userId: z.string().uuid().min(1), // Validates as a required UUID string 23 | jwt: z.string(), // Validates as a required string 24 | }); 25 | 26 | @inject(BINDINGS.UsersRepository) 27 | private _usersRepository: any; 28 | 29 | async execute(validatedUserData: any) { 30 | const { userId, jwt } = validatedUserData; 31 | 32 | try { 33 | const jwtSign = jwt.split('.')[2]; 34 | const deletedTokensCount = 35 | await this._usersRepository.delRefreshTokenForUser(userId); 36 | 37 | // clear userId - user map 38 | await this._memoryStorage.delValue(userId); 39 | await this._memoryStorage.setValue(jwtSign, true); 40 | 41 | return { deletedTokensCount }; 42 | } catch (error) { 43 | logger.error('LogoutUser:error', error); 44 | 45 | if (typeof error === 'string') { 46 | throw new Error(error); 47 | } else if (error instanceof Error) { 48 | throw new Error(error.message); 49 | } else { 50 | throw new Error('An unexpected error occurred'); 51 | } 52 | } 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /llm-codegen/core/llmClients/deepSeekLLMClient.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | 3 | import { DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '../constants'; 4 | import logger from '../logger'; 5 | import { delay } from '../utils'; 6 | import { BaseLLMClient } from './baseLLMClient'; 7 | 8 | const DEEP_SEEK_BASE_URL = 'https://api.deepseek.com'; 9 | const DEEP_SEEK_CHAT_MODEL_ID = 'deepseek-chat'; 10 | 11 | /** 12 | * @class DeepSeek LLM client 13 | * 14 | * The DeepSeekLLMClient class interacts with DeepSeek's chat API. 15 | */ 16 | export class DeepSeekLLMClient extends BaseLLMClient { 17 | openai: OpenAI; 18 | 19 | constructor() { 20 | super(); 21 | this.openai = new OpenAI({ 22 | baseURL: DEEP_SEEK_BASE_URL, 23 | apiKey: process.env.DEEP_SEEK_API_KEY, 24 | }); 25 | } 26 | 27 | async execute( 28 | prompt: string, 29 | maxTokens = DEFAULT_MAX_TOKENS, 30 | temperature = DEFAULT_TEMPERATURE, 31 | retry = 0 32 | ): Promise<{ content: string; inputTokens?: number; outputTokens?: number }> { 33 | try { 34 | const completion = await this.openai.chat.completions.create({ 35 | model: DEEP_SEEK_CHAT_MODEL_ID, 36 | messages: [ 37 | { 38 | role: 'user', 39 | content: prompt, 40 | }, 41 | ], 42 | }); 43 | 44 | return { 45 | content: completion?.choices?.[0].message?.content || '', 46 | inputTokens: completion.usage?.prompt_tokens, 47 | outputTokens: completion.usage?.completion_tokens, 48 | }; 49 | } catch (ex) { 50 | logger.error(`DeepSeekLLMClient:execute:retry=${retry}:ex=`, ex); 51 | 52 | if (retry < 5) { 53 | // wait random seconds 54 | await delay(10 + Math.random() * 10); 55 | 56 | return this.execute(prompt, maxTokens, temperature, retry + 1); 57 | } 58 | 59 | throw ex; 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/modules/todos/addTodo.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable, z } from '../../common/types'; 2 | 3 | import Operation from '../../common/operation'; 4 | import logger from '../../infra/loaders/logger'; 5 | 6 | import { BINDINGS } from '../../common/constants'; 7 | import { Todo } from './types'; 8 | import useTransaction from '../../common/useTransaction'; 9 | import { camelToSnake, snakeToCamel } from '../../common/utils'; 10 | 11 | 12 | /** 13 | * @class AddTodos 14 | * 15 | * Service class to handle adding todos for a user. 16 | */ 17 | @useTransaction() 18 | @injectable() 19 | export class AddTodo extends Operation { 20 | 21 | /** 22 | * Validation rules for input data using Zod schema. 23 | * @type {ZodSchema} 24 | */ 25 | static validationRules = z.object({ 26 | userId: z.string().uuid().min(1), // user UUID 27 | content: z.string().min(2).max(200), // each string has a min length of 2 and max of 200 28 | fileSrc: z.string().min(2).optional(), 29 | expiresAt: z.string().min(2).optional(), 30 | }); 31 | 32 | /** 33 | * The todos repository instance. 34 | * @private 35 | * @type {TodosRepository} 36 | */ 37 | @inject(BINDINGS.TodosRepository) 38 | private _todosRepository: any; 39 | 40 | async execute(this: AddTodo, validatedUserData: any): Promise { 41 | const { userId, content, fileSrc, expiresAt } = validatedUserData; 42 | 43 | try { 44 | logger.info(`AddTodos:execute:userId=${userId}:content=${content}:expiresAt=${expiresAt}`); 45 | 46 | const addedTodos = await this._todosRepository.addTodo(userId, camelToSnake({ 47 | content, 48 | fileSrc, 49 | expiresAt 50 | })); 51 | 52 | return snakeToCamel(addedTodos[0]); 53 | } catch (error) { 54 | logger.error( 55 | `AddTodos:error:${(error as Error).name}:${(error as Error).message}` 56 | ); 57 | throw error; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /llm-codegen/core/prompts/troubleshooter.main.prompt: -------------------------------------------------------------------------------- 1 | # Node.js TypeScript API Module Troubleshooter 2 | 3 | You are a world-class full-stack software engineer specializing in debugging and fixing TypeScript compilation errors in Node.js API modules. 4 | 5 | ## Context 6 | A Node.js TypeScript API module was generated for the following project: 7 | 8 | **Project Description:** 9 | {{PROJECT_DESCRIPTION}} 10 | 11 | ## Below are each of the generated layers in the codebase: 12 | 13 | ### ROUTES: 14 | {{MODULE_ROUTES}} 15 | 16 | ### CONTROLLERS: 17 | {{MODULE_CONTROLLERS}} 18 | 19 | ### SERVICE - updateTodo: 20 | {{SERVICES_EXAMPLE}} 21 | 22 | {{MODULE_SERVICES}} 23 | 24 | ### REPOSITORY: 25 | {{MODULE_REPOSITORY}} 26 | 27 | ### DI_CONFIG: 28 | {{DI_CONFIG_EXAMPLE}} 29 | 30 | ### TYPES: 31 | {{MODULE_TYPES}} 32 | 33 | ### E2E_TESTS: 34 | {{MODULE_E2E_TESTS}} 35 | 36 | ### ALL_API_ROUTES 37 | {{ALL_API_ROUTES}} 38 | 39 | ### ALL_DI_CONFIG 40 | {{ALL_DI_CONFIG}} 41 | 42 | ### ALL_CONSTANTS 43 | {{ALL_CONSTANTS}} 44 | 45 | **You received the following error after compilation:** 46 | ``` 47 | {{ERROR_TEXT}} 48 | ``` 49 | 50 | ## Fix Requirements 51 | 52 | Based on the above-generated code and error, output **ONLY** the module code that is needed to fix the error. Please structure your output using the following format: 53 | 54 | - Begin each section with `### ` followed by the section name in uppercase (e.g., `### TYPES:`). 55 | - Do not include any new sections. 56 | - Do not include numbering or extra text in the section headers. 57 | - Provide only the code, enclosed in triple backticks with the appropriate language identifier (e.g., ```typescript). 58 | - Do not include any explanatory text or file creation instructions. 59 | - Generate code for necessary service classes and output it under the appropriate section name - for example "### SERVICE - updateTodo". 60 | - When generating E2E tests, keep in mind that the database contains records inserted by your generated ALL_SEEDS script. 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/modules/todos/getUserTodos.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable, toCamelCase, z } from '../../common/types'; 2 | 3 | import Operation from '../../common/operation'; 4 | import logger from '../../infra/loaders/logger'; 5 | 6 | import { BINDINGS } from '../../common/constants'; 7 | import { Todo } from './types'; 8 | 9 | /** 10 | * @class GetUserTodos 11 | * 12 | * Service implementation to load all user todos 13 | */ 14 | @injectable() 15 | export class GetUserTodos extends Operation { 16 | static validationRules = z.object({ 17 | userId: z.string().uuid().min(1), // UUID and required (nonempty) 18 | search: z 19 | .string() 20 | .max(50) 21 | .optional() 22 | .or(z.literal('').or(z.null())) 23 | .refine((val) => /^[a-zA-Z0-9]*$/.test(val || ''), { 24 | message: 'Must be alphanumeric', 25 | }), // Allows empty string, null, and alphanumeric 26 | pageSize: z.number().int().min(1).max(100).optional(), // Integer between 1 and 100 27 | pageInd: z.number().int().min(0).max(10000).optional(), // Integer between 0 and 10000 28 | }); 29 | 30 | @inject(BINDINGS.TodosRepository) 31 | private _todosRepository: any; 32 | 33 | async execute(this: GetUserTodos, validatedUserData: any): Promise { 34 | const { 35 | userId, 36 | pageSize = 100, 37 | pageInd = 0, 38 | search = '', 39 | } = validatedUserData; 40 | try { 41 | logger.info(`GetUserTodos:execute userId=${userId}`); 42 | 43 | const todos = 44 | (await this._todosRepository.findUserTodos( 45 | userId, 46 | pageInd, 47 | pageSize, 48 | search 49 | )) || []; 50 | 51 | return todos.map((t) => toCamelCase(t)); 52 | } catch (error) { 53 | logger.error('GetUserTodos:error', error); 54 | if (typeof error === 'string') { 55 | throw new Error(error); 56 | } else if (error instanceof Error) { 57 | throw new Error(error.message); 58 | } else { 59 | throw new Error('An unexpected error occurred'); 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/infra/loaders/express.ts: -------------------------------------------------------------------------------- 1 | import * as bodyParser from 'body-parser'; 2 | import cors from 'cors'; 3 | import morgan from 'morgan'; 4 | import helmet from 'helmet'; 5 | import compression from 'compression'; 6 | import rTracer from 'cls-rtracer'; 7 | 8 | import { 9 | Application, 10 | Request, 11 | Response, 12 | HTTP_STATUS, 13 | } from '../../common/types'; 14 | import config from '../../config/app'; 15 | import { routes } from '../../modules'; 16 | import logger from './logger'; 17 | 18 | export default ({ app }: { app: Application }) => { 19 | app.head('/status', (req: Request, res: Response) => { 20 | res.status(HTTP_STATUS.OK).end(); 21 | }); 22 | 23 | app.get('/', (req: Request, res: Response) => { 24 | res.json({ info: 'API' }).end(); 25 | }); 26 | 27 | app.get('/status', (req: Request, res: Response) => { 28 | res.json({ version: 'API v.1.0.5' }).end(); 29 | }); 30 | 31 | app.enable('trust proxy'); 32 | 33 | app.use(bodyParser.json()); 34 | app.use(bodyParser.urlencoded({ extended: true })); 35 | 36 | app.use(cors()); 37 | app.use(compression()); 38 | app.use(helmet()); 39 | app.use(morgan('combined')); 40 | app.use(bodyParser.urlencoded({ extended: false })); 41 | 42 | app.use(rTracer.expressMiddleware()); 43 | // init API routes 44 | app.use(config.api.prefix, routes()); 45 | 46 | // catch 404 and forward to error handler 47 | app.use((req, res, next) => { 48 | const err = new Error('Requested resource not found'); 49 | err['status'] = HTTP_STATUS.NOT_FOUND; 50 | next(err); 51 | }); 52 | 53 | // error handlers 54 | app.use((err, req, res, next) => { 55 | /** 56 | * Handle 401 thrown by express-jwt library 57 | */ 58 | if (err.name === 'UnauthorizedError') { 59 | return res 60 | .status(err.status) 61 | .json({ error: `UnauthorizedError:${err.message}` }) 62 | .end(); 63 | } 64 | return next(err); 65 | }); 66 | 67 | app.use((err, req, res, next) => { 68 | res.status(err.status || HTTP_STATUS.INTERNAL_SERVER_ERROR); 69 | res.json({ 70 | error: err.message, 71 | }); 72 | }); 73 | 74 | logger.info('express initialized'); 75 | 76 | return app; 77 | }; 78 | -------------------------------------------------------------------------------- /src/config/app.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | 5 | const { NODE_ENV = 'dev' } = process.env; 6 | 7 | const envPath = 8 | NODE_ENV === 'test' 9 | ? path.resolve(__dirname + '/../../.env.test') 10 | : path.resolve(__dirname + '/../../.env'); 11 | 12 | const envFileExists = fs.existsSync(envPath); 13 | 14 | if (envFileExists && ['dev', 'test'].includes(NODE_ENV)) { 15 | const result = dotenv.config({ 16 | path: envPath, 17 | }); 18 | 19 | if (result.error) { 20 | throw result.error; 21 | } 22 | } 23 | 24 | const { 25 | DB_URI, 26 | JWT_SECRET, 27 | LOG_LEVEL = 'info', 28 | AWS_BUCKET, 29 | AWS_ACCESS_KEY, 30 | AWS_SECRET, 31 | AWS_REGION, 32 | 33 | REDIS_URI, 34 | 35 | PORT = 3000, 36 | } = process.env; 37 | 38 | type AppConfig = { 39 | [key: string]: any; 40 | }; 41 | 42 | const appConfig: AppConfig = { 43 | awsBucket: AWS_BUCKET, 44 | awsRegion: AWS_REGION, 45 | awsAccessKey: AWS_ACCESS_KEY, 46 | awsSecretAccessKey: AWS_SECRET, 47 | 48 | hashSalt: 'r4yw2!', 49 | env: NODE_ENV, 50 | /** 51 | * Your favorite port 52 | */ 53 | // port: parseInt(PORT as string, 10), 54 | port: Number(PORT), 55 | 56 | /** 57 | * That long string from mlab 58 | */ 59 | databaseURL: DB_URI, 60 | 61 | /** 62 | * Redis connection string 63 | */ 64 | redisUri: REDIS_URI, 65 | /** 66 | * Identifies each client app which requests JWT 67 | */ 68 | defaultClientId: '9781', 69 | 70 | /** 71 | * Duration period for refresh token in seconds, set it to 90 days by default 72 | */ 73 | refreshTokenDuration: 90 * 24 * 60 * 60, 74 | 75 | /** 76 | * Your secret sauce for JWT token 77 | */ 78 | jwtSecret: JWT_SECRET, 79 | 80 | /** 81 | * Duration period for JWT token in seconds, set it to 8h 82 | */ 83 | jwtDuration: 8 * 60 * 60, 84 | 85 | /** 86 | * Used by winston logger 87 | */ 88 | logs: { 89 | level: LOG_LEVEL, 90 | }, 91 | 92 | /** 93 | * API configs 94 | */ 95 | api: { 96 | prefix: '/api', 97 | }, 98 | }; 99 | 100 | export default appConfig; 101 | -------------------------------------------------------------------------------- /llm-codegen/core/llmClients/openAILLMClient.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | 3 | import { DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '../constants'; 4 | import logger from '../logger'; 5 | import { delay } from '../utils'; 6 | import { BaseLLMClient } from './baseLLMClient'; 7 | 8 | const { OPENAI_MODEL_ID = 'gpt-4o-mini' } = process.env; 9 | 10 | /** 11 | * @class OpenAILLMClient 12 | * 13 | * The OpenAILLMClient class interacts with OpenAI's LLM API. 14 | * It sends prompts to the specified OpenAI model and retrieves the generated responses. 15 | * The client includes retry logic to handle transient server errors, ensuring reliable communication with the API. 16 | */ 17 | export class OpenAILLMClient extends BaseLLMClient { 18 | openai: OpenAI; 19 | 20 | constructor() { 21 | super(); 22 | this.openai = new OpenAI({ 23 | apiKey: process.env.OPENAI_API_KEY, 24 | }); 25 | } 26 | 27 | async execute( 28 | prompt: string, 29 | maxTokens = DEFAULT_MAX_TOKENS, 30 | temperature = DEFAULT_TEMPERATURE, 31 | retry = 0 32 | ): Promise<{ content: string; inputTokens?: number; outputTokens?: number }> { 33 | try { 34 | const response = await this.openai.chat.completions.create({ 35 | model: OPENAI_MODEL_ID, 36 | messages: [{ role: 'user', content: prompt }], 37 | max_tokens: maxTokens, 38 | temperature, 39 | }); 40 | 41 | return { 42 | content: response.choices[0].message.content || '', 43 | inputTokens: response.usage?.prompt_tokens, 44 | outputTokens: response.usage?.completion_tokens, 45 | }; 46 | } catch (ex) { 47 | logger.error(`OpenAILLMClient:execute:retry=${retry}:ex=`, ex); 48 | if ( 49 | retry < 5 && 50 | ex instanceof OpenAI.APIError && 51 | Number(ex.status || 0) >= 500 52 | ) { 53 | logger.error( 54 | `OpenAILLMClient:RETRY:error=`, 55 | ex.status, 56 | ex.name, 57 | 'retry=', 58 | retry 59 | ); 60 | // wait random seconds 61 | await delay(10 + Math.random() * 10); 62 | 63 | return this.execute(prompt, maxTokens, temperature, retry + 1); 64 | } 65 | 66 | throw ex; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/common/operation.ts: -------------------------------------------------------------------------------- 1 | import { injectable, inject } from './types'; 2 | import BaseRepository from './baseRepository'; 3 | import { BINDINGS } from './constants'; 4 | 5 | /** 6 | * @class BaseOperation 7 | * 8 | * Serves as the base class for all service operations, providing common functionalities 9 | * such as validation, repository management, and execution flow control. 10 | */ 11 | @injectable() 12 | class BaseOperation { 13 | @inject(BINDINGS.MemoryStorage) 14 | protected _memoryStorage: any; 15 | 16 | @inject(BINDINGS.UsersRepository) 17 | private _usersRepo: any; 18 | 19 | // get all repositories injected into current instance 20 | getRepositories() { 21 | const repositories: BaseRepository[] = []; 22 | 23 | Object.getOwnPropertyNames(this).forEach((prop) => { 24 | if (this[prop] instanceof BaseRepository) { 25 | repositories.push(this[prop]); 26 | } 27 | }); 28 | 29 | return repositories; 30 | } 31 | 32 | validate(params: any): any { 33 | if (this.constructor['validationRules']) { 34 | // read static ZOD property and parse 35 | return this.constructor['validationRules'].safeParse(params); 36 | } 37 | return { 38 | data: null, 39 | error: `"validationRules" property are missing in the service definition`, 40 | }; 41 | } 42 | 43 | // empty base implementation 44 | async execute(params: any): Promise {} 45 | 46 | async run(params: any): Promise { 47 | const { data: validated, error } = this.validate(params); 48 | 49 | if (typeof error === 'string') { 50 | throw new Error(error); 51 | } else if (error) { 52 | // throw validation error 53 | throw error; 54 | } 55 | 56 | const { userId } = validated || {}; 57 | let user = null; 58 | 59 | if (userId) { 60 | user = await this._memoryStorage.getValue(userId); 61 | 62 | if (!user) { 63 | user = await this._usersRepo.findById(userId); 64 | 65 | if (user) { 66 | await this._memoryStorage.setValue(userId, user); 67 | } else { 68 | throw new Error('JWT_TOKEN_HAS_INVALID_USER'); 69 | } 70 | } 71 | } 72 | 73 | return this.execute({ ...validated, user }); 74 | } 75 | } 76 | 77 | export default BaseOperation; 78 | -------------------------------------------------------------------------------- /llm-codegen/core/llmClients/anthropicLLMClient.ts: -------------------------------------------------------------------------------- 1 | import Anthropic from '@anthropic-ai/sdk'; 2 | 3 | import { DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '../constants'; 4 | import logger from '../logger'; 5 | import { delay } from '../utils'; 6 | import { BaseLLMClient } from './baseLLMClient'; 7 | 8 | const { ANTHROPIC_CLAUDE_MODEL_ID = 'claude-3-5-haiku-20241022' } = process.env; 9 | 10 | /** 11 | * @class AnthropicLLMClient 12 | * 13 | * The AnthropicLLMClient class interacts with Anthropic's LLM API. 14 | * It sends prompts to the specified Anthropic model and retrieves the generated responses. 15 | * The client handles retry logic for transient server errors to ensure robust communication with the API. 16 | */ 17 | export class AnthropicLLMClient extends BaseLLMClient { 18 | anthropic: Anthropic; 19 | 20 | constructor() { 21 | super(); 22 | this.anthropic = new Anthropic({ 23 | apiKey: process.env.ANTHROPIC_API_KEY, 24 | }); 25 | } 26 | 27 | async execute( 28 | prompt: string, 29 | maxTokens = DEFAULT_MAX_TOKENS, 30 | temperature = DEFAULT_TEMPERATURE, 31 | retry = 0 32 | ): Promise<{ content: string; inputTokens?: number; outputTokens?: number }> { 33 | try { 34 | const message = await this.anthropic.messages.create({ 35 | max_tokens: maxTokens, 36 | messages: [{ role: 'user', content: prompt }], 37 | model: ANTHROPIC_CLAUDE_MODEL_ID, 38 | }); 39 | return { 40 | content: (message.content?.[0] as { text: string }).text || '', 41 | inputTokens: message.usage?.input_tokens, 42 | outputTokens: message.usage?.output_tokens, 43 | }; 44 | } catch (ex) { 45 | logger.error(`AnthropicLLMClient:execute:retry=${retry}:ex=`, ex); 46 | if ( 47 | retry < 5 && 48 | ex instanceof Anthropic.APIError && 49 | Number(ex.status || 0) >= 500 50 | ) { 51 | logger.error( 52 | `AnthropicLLMClient:RETRY:error=`, 53 | ex.status, 54 | ex.name, 55 | 'retry=', 56 | retry 57 | ); 58 | // wait random seconds 59 | await delay(10 + Math.random() * 10); 60 | 61 | return this.execute(prompt, maxTokens, temperature, retry + 1); 62 | } 63 | 64 | throw ex; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /llm-codegen/core/utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import logger from './logger'; 4 | 5 | export const capitalizeFirstLetter = (string: string) => { 6 | return string.charAt(0).toUpperCase() + string.slice(1); 7 | }; 8 | 9 | export const lowerCaseFirstLetter = (string: string) => { 10 | return string.charAt(0).toLowerCase() + string.slice(1); 11 | }; 12 | 13 | export const extractFileName = (input: string): string => { 14 | // Regular expression explanation: 15 | // - Look for a separator (e.g., -, --, :, etc.) possibly with spaces: [\-:]*\s* 16 | // - Capture a group of word characters (including camelCase and numbers): ([A-Za-z0-9]+) 17 | // - Followed by non-word characters or end of string: \W|$ 18 | 19 | if (!input) { 20 | return ''; 21 | } 22 | const regex = /[-:]*\s*([A-Za-z][A-Za-z0-9]*)\W*$/; 23 | const match = input.match(regex); 24 | 25 | if (match && match[1]) { 26 | return match[1]; 27 | } 28 | 29 | return ''; 30 | }; 31 | 32 | export const delay = (seconds: number) => 33 | new Promise((resolve) => setTimeout(resolve, seconds)); 34 | 35 | export const buildTree = (paths: string[]): Record => { 36 | const tree: Record = {}; 37 | 38 | for (const filePath of paths) { 39 | const parts = filePath.split(path.sep); 40 | let current = tree; 41 | 42 | for (let i = 0; i < parts.length; i++) { 43 | const part = parts[i]; 44 | if (!current[part]) { 45 | // If it's the last part, it's a file; else, it's a directory (object) 46 | current[part] = i === parts.length - 1 ? null : {}; 47 | } 48 | current = current[part] || {}; 49 | } 50 | } 51 | 52 | return tree; 53 | }; 54 | 55 | export const printTree = ( 56 | tree: Record, 57 | prefix = '', 58 | isLast = true 59 | ) => { 60 | const entries = Object.keys(tree); 61 | entries.forEach((entry, index) => { 62 | const last = index === entries.length - 1; 63 | const connector = last ? '└─ ' : '├─ '; 64 | logger.info(prefix + connector + entry); 65 | 66 | const value = tree[entry]; 67 | if (value && typeof value === 'object') { 68 | // For directories, print children with updated prefix 69 | const newPrefix = prefix + (last ? ' ' : '│ '); 70 | printTree(value, newPrefix, last); 71 | } 72 | }); 73 | }; 74 | -------------------------------------------------------------------------------- /llm-codegen/core/llmClients/openRouterLLMClient.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | 3 | import { DEFAULT_MAX_TOKENS, DEFAULT_TEMPERATURE } from '../constants'; 4 | import logger from '../logger'; 5 | import { delay } from '../utils'; 6 | import { BaseLLMClient } from './baseLLMClient'; 7 | 8 | const OPEN_ROUTER_APP_NAME = 'LLM-CODEGEN-NODEJS-BOILERPLATE-VY'; 9 | const OPEN_ROUTER_LLAMA_8B_MODEL_ID = 10 | 'nousresearch/deephermes-3-llama-3-8b-preview:free' 11 | 12 | /** 13 | * @class OpenRouterLLMClient 14 | * 15 | * The OpenRouterLLMClient class interacts with OpenRouter's LLM API. 16 | * OpenRouter normalizes requests and responses across different LLM providers, ensuring consistent interaction. 17 | * It sends prompts to the specified OpenRouter model and retrieves the generated responses. 18 | * The client includes retry logic to handle transient server errors, ensuring reliable communication with the API. 19 | */ 20 | export class OpenRouterLLMClient extends BaseLLMClient { 21 | openai: OpenAI; 22 | 23 | constructor() { 24 | super(); 25 | this.openai = new OpenAI({ 26 | baseURL: 'https://openrouter.ai/api/v1', 27 | apiKey: process.env.OPEN_ROUTER_API_KEY, 28 | defaultHeaders: { 29 | 'X-Title': OPEN_ROUTER_APP_NAME, 30 | }, 31 | }); 32 | } 33 | 34 | async execute( 35 | prompt: string, 36 | maxTokens = DEFAULT_MAX_TOKENS, 37 | temperature = DEFAULT_TEMPERATURE, 38 | retry = 0 39 | ): Promise<{ content: string; inputTokens?: number; outputTokens?: number }> { 40 | try { 41 | const completion = await this.openai.chat.completions.create({ 42 | model: OPEN_ROUTER_LLAMA_8B_MODEL_ID, 43 | messages: [ 44 | { 45 | role: 'user', 46 | content: prompt, 47 | }, 48 | ], 49 | }); 50 | 51 | return { 52 | content: completion?.choices?.[0].message?.content || '', 53 | inputTokens: completion.usage?.prompt_tokens, 54 | outputTokens: completion.usage?.completion_tokens, 55 | }; 56 | } catch (ex) { 57 | logger.error(`OpenRouterLLMClient:execute:retry=${retry}:ex=`, ex); 58 | 59 | if (retry < 5) { 60 | // wait random seconds 61 | await delay(10 + Math.random() * 10); 62 | 63 | return this.execute(prompt, maxTokens, temperature, retry + 1); 64 | } 65 | 66 | throw ex; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /llm-codegen/core/prompts/testsFixer.main.prompt: -------------------------------------------------------------------------------- 1 | # Node.js TypeScript E2E Test Debugger 2 | 3 | You are a world-class full-stack software engineer and automation testing specialist. Your expertise lies in diagnosing and fixing E2E test failures in Node.js TypeScript API modules. 4 | 5 | ## Project Context 6 | 7 | **Project Description:** 8 | {{PROJECT_DESCRIPTION}} 9 | 10 | ## Below are each of the generated layers in the codebase: 11 | 12 | ### ROUTES: 13 | {{MODULE_ROUTES}} 14 | 15 | ### CONTROLLERS: 16 | {{MODULE_CONTROLLERS}} 17 | 18 | ### SERVICE - updateTodo: 19 | {{SERVICES_EXAMPLE}} 20 | 21 | {{MODULE_SERVICES}} 22 | 23 | ### REPOSITORY: 24 | {{MODULE_REPOSITORY}} 25 | 26 | ### ALL_SEEDS 27 | {{ALL_SEEDS}} 28 | 29 | ### MIGRATIONS: 30 | {{MODULE_MIGRATIONS}} 31 | 32 | ### E2E_TESTS: 33 | {{MODULE_E2E_TESTS}} 34 | 35 | **You received the following error output after running E2E unit tests:** 36 | ``` 37 | {{ERROR_TEXT}} 38 | ``` 39 | 40 | ## Fix Requirements 41 | 42 | Analyze the test failures and provide **ONLY** the corrected code sections (E2E_TESTS, MIGRATIONS, SERVICE ) or other required to resolve the issues. 43 | 44 | Please structure your output using the following format: 45 | 46 | - Analyze the error output to identify the root cause of the failures. 47 | - Begin each section with `### ` followed by the section name in uppercase (e.g., `### E2E_TESTS:`). 48 | - Do not include numbering or extra text in the section headers. 49 | - Provide only the code, enclosed in triple backticks with the appropriate language identifier (e.g., ```typescript). 50 | - Generate code for all necessary service classes and output it under the appropriate section name - for example "### SERVICE - updateTodo". 51 | - Do not include any explanatory text or file creation instructions. 52 | - When generating E2E tests, ensure they closely follow the patterns, structure, and conventions shown in the provided example E2E tests. 53 | - When generating E2E tests, keep in mind that the database contains records inserted by your generated ALL_SEEDS script. 54 | - When a condition fails to produce the expected result, identify the relevant service, verify its implementation to ensure it returns the correct data, and update it if necessary. 55 | - When generating E2E tests, ensure that the response content matches specific expected values or specific object properties. 56 | - For E2E tests, ensure that ONLY the 200 (OK) status code is used for successful HTTP requests 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/modules/users/refreshToken.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable, toCamelCase, z } from '../../common/types'; 2 | import Operation from '../../common/operation'; 3 | import useRateLimiter from '../../common/useRateLimiter'; 4 | import { generateJWT } from './authUtils'; 5 | import { BINDINGS } from '../../common/constants'; 6 | import appConfig from '../../config/app'; 7 | import logger from '../../infra/loaders/logger'; 8 | import _ from 'lodash'; 9 | 10 | 11 | /** 12 | * @class RefreshToken 13 | * 14 | * Handles the refresh token operation, validating the refresh token, 15 | * retrieving the associated user, and generating a new JWT. 16 | */ 17 | @useRateLimiter('REFRESH_TOKEN_PER_HOUR_BY_IP', { 18 | points: 5, // 5 calls 19 | duration: 60 * 60, // per 1 hour 20 | blockDuration: 60 * 60, // block on 1 hour 21 | }) 22 | @injectable() 23 | export class RefreshToken extends Operation { 24 | static validationRules = z.object({ 25 | refreshToken: z.string().min(1), // Validates as a required string 26 | clientId: z.string().optional(), // Validates as an optional string 27 | }); 28 | 29 | @inject(BINDINGS.UsersRepository) private _usersRepository: any; 30 | 31 | async execute(validatedUserData: any) { 32 | let result = { jwt: null }; 33 | const { refreshToken, clientId = appConfig.defaultClientId } = 34 | validatedUserData; 35 | if (!refreshToken) { 36 | throw new Error('EMPTY_REFRESH_TOKEN'); 37 | } 38 | 39 | try { 40 | // TODO: check for refresh token exp date 41 | let user = await this._usersRepository.findByRefreshToken( 42 | refreshToken, 43 | clientId 44 | ); 45 | 46 | if (user) { 47 | logger.info(`RefreshToken found user`); 48 | 49 | this._memoryStorage.setValue(user.id, user); 50 | 51 | const jwt = generateJWT(user); 52 | 53 | return { 54 | user: _.omit(toCamelCase(user), ['password', 'refreshToken']), 55 | jwt, 56 | }; 57 | } else { 58 | logger.warn(`RefreshToken user not found for token ${refreshToken}`); 59 | } 60 | } catch (error) { 61 | logger.error('RefreshToken:error', error); 62 | 63 | if (typeof error === 'string') { 64 | throw new Error(error); 65 | } else if (error instanceof Error) { 66 | throw new Error(error.message); 67 | } else { 68 | throw new Error('An unexpected error occurred'); 69 | } 70 | } 71 | 72 | return result; 73 | } 74 | } 75 | 76 | -------------------------------------------------------------------------------- /src/infra/data/seeds/init.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from '../../../common/types'; 2 | import { v4 as uuid } from 'uuid'; 3 | import { nanoid } from 'nanoid'; 4 | import { hashPassword } from '../../../modules/users/authUtils'; 5 | import appConfig from '../../../config/app'; 6 | 7 | const { SKIP_IF_ALREADY_RUN } = process.env; 8 | 9 | export async function seed(knex: Knex): Promise { 10 | // check if can skip seed phase 11 | if (SKIP_IF_ALREADY_RUN === 'true') { 12 | const adminUser = await knex('users').where('user_name', 'admin').first(); 13 | 14 | if (adminUser) { 15 | console.log('Skip seed phase because flag SKIP_IF_ALREADY_RUN is true'); 16 | return; 17 | } 18 | } 19 | 20 | await knex('todos').del(); 21 | await knex('user_refresh_tokens').del(); 22 | await knex('users').del(); 23 | 24 | // make constant admin id 25 | const adminId = uuid(); 26 | const refreshToken = '50ecc6dcbd1a'; 27 | 28 | const [u1, u2, u3] = Array(3) 29 | .fill(null) 30 | .map(() => uuid()); 31 | 32 | // inserts seed entries 33 | await knex('users').insert([ 34 | { 35 | id: adminId, 36 | user_name: 'admin', 37 | email: 'admin@example-todos-api.com', 38 | role: 'admin', 39 | password: hashPassword('123456'), 40 | }, 41 | { id: u1, user_name: 'jon.doe', role: 'registered' }, 42 | { id: u2, user_name: 'homer.simpson', role: 'registered' }, 43 | { id: u3, user_name: 'jon.gold', role: 'registered' }, 44 | ]); 45 | 46 | // 7 days refresh token expiration 47 | const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); 48 | await knex('user_refresh_tokens').insert([ 49 | { 50 | id: uuid(), 51 | user_id: adminId, 52 | client_id: appConfig.defaultClientId, 53 | expires, 54 | refresh_token: refreshToken, 55 | }, 56 | ...[u1, u2, u3].map((uId) => ({ 57 | id: uuid(), 58 | user_id: uId, 59 | client_id: appConfig.defaultClientId, 60 | expires, 61 | refresh_token: nanoid(), 62 | })), 63 | ]); 64 | 65 | await knex('todos').insert([ 66 | { id: uuid(), content: 'Do exercises', user_id: adminId }, 67 | { id: uuid(), content: 'Check email', user_id: adminId }, 68 | { 69 | id: uuid(), 70 | content: 'Call to bank', 71 | user_id: adminId, 72 | expires_at: (new Date(2025, 1, 1, 12, 30, 0)), 73 | expired: false, 74 | }, 75 | { id: uuid(), content: 'Order pizza', user_id: adminId }, 76 | { id: uuid(), content: 'Pay bills', user_id: adminId }, 77 | ]); 78 | 79 | } -------------------------------------------------------------------------------- /src/tests/test-setup.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import { container } from '../infra/loaders/diContainer'; 3 | import app from '../app'; 4 | import { addHeaders } from './utils'; 5 | import { HTTP_STATUS } from '../common/types'; 6 | import { BINDINGS } from '../common/constants'; 7 | 8 | // use both mock approaches - by rebinding Di container and by using regular mock 9 | container.rebind(BINDINGS.MemoryStorage).toConstantValue({ 10 | setValue: jest.fn(), 11 | getValue: jest.fn(), 12 | delValue: jest.fn(), 13 | }); 14 | 15 | jest.mock('../infra/integrations/aws.service', () => ({ 16 | saveToS3: jest.fn().mockReturnValue('http://test_bucket_url'), 17 | })); 18 | 19 | jest.mock('rate-limiter-flexible', () => ({ 20 | RateLimiterRedis: function MockRateLimiterRedis() { 21 | return { 22 | consume() { }, 23 | penalty() { }, 24 | reward() { }, 25 | block() { }, 26 | get() { 27 | return null; 28 | }, 29 | set() { }, 30 | delete() { }, 31 | getKey() { }, 32 | }; 33 | }, 34 | })); 35 | 36 | jest.mock('ioredis', () => { 37 | return jest.fn().mockImplementation(() => ({ 38 | connect: jest.fn(), 39 | quit: jest.fn(), 40 | set: jest.fn(), 41 | get: jest.fn(), 42 | })); 43 | }); 44 | 45 | jest.mock('cls-rtracer', () => ({ 46 | expressMiddleware: () => (_req, _res, next) => next(), 47 | })); 48 | 49 | jest.setTimeout(10000); 50 | 51 | beforeAll(async () => { 52 | await app['initLoaders'](); 53 | // get tokens and validate using expect that setup goes properly 54 | const request = supertest(app); 55 | 56 | let response = await addHeaders(request.post('/api/signup')); 57 | 58 | expect(response.status).toBe(HTTP_STATUS.CREATED); 59 | expect(response.body.result).toEqual(expect.anything()); 60 | expect(response.body.result.jwt).toEqual(expect.anything()); 61 | 62 | // store jwt tokens in env vars, is only one possible way according discussions in https://github.com/facebook/jest/issues/7184 63 | // https://stackoverflow.com/questions/54654040/how-to-share-an-object-between-multiple-test-suites-in-jest 64 | process.env['ANONYM_JWT_TOKEN'] = response.body.result.jwt; 65 | 66 | response = await addHeaders( 67 | request.post('/api/signin').send({ 68 | username: 'admin', 69 | password: '123456', 70 | }) 71 | ); 72 | 73 | expect(response.status).toBe(HTTP_STATUS.OK); 74 | expect(response.body.result).toEqual(expect.anything()); 75 | expect(response.body.result.jwt).toEqual(expect.anything()); 76 | 77 | process.env['ADMIN_JWT_TOKEN'] = response.body.result.jwt; 78 | }); 79 | -------------------------------------------------------------------------------- /src/modules/todos/repository.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | 3 | import BaseRepository from '../../common/baseRepository'; 4 | import { injectable } from '../../common/types'; 5 | import logger from '../../infra/loaders/logger'; 6 | import { Todo } from './types'; 7 | 8 | /** 9 | * @class TodosRepository 10 | * 11 | * Manages todo-related database operations, including retrieving, creating, updating, and deleting Todo items. 12 | */ 13 | @injectable() 14 | export class TodosRepository extends BaseRepository { 15 | async findAll( 16 | pageInd: number, 17 | pageSize: number, 18 | search: string 19 | ): Promise { 20 | const qb = this.dbAccess!('todos').returning('*'); 21 | logger.info('TodosRepository:findAll'); 22 | return this.wrapWithPaginationAndSearch(qb, pageInd, pageSize, [ 23 | { field: 'content', search }, 24 | ]); 25 | } 26 | 27 | async findUserTodos( 28 | userId: string, 29 | pageInd: number, 30 | pageSize: number, 31 | search: string 32 | ): Promise { 33 | const qb = this.dbAccess('todos') 34 | .select('*') 35 | .where('user_id', userId); 36 | 37 | return this.wrapWithPaginationAndSearch(qb, pageInd, pageSize, [ 38 | { field: 'content', search }, 39 | ]); 40 | } 41 | 42 | async findById(id: string): Promise { 43 | return this.dbAccess('todos').select('*').where('id', id).first(); 44 | } 45 | 46 | async addTodo( 47 | userId: string, 48 | todo: { 49 | context: string; 50 | file_src?: string; 51 | expires_at?: string; 52 | expired?: boolean; 53 | } 54 | ) { 55 | return this.dbAccess('todos') 56 | .insert([{ id: uuid(), user_id: userId, ...todo }]) 57 | .returning('*'); 58 | } 59 | 60 | async updateTodo( 61 | todoId: string, 62 | userId: string, 63 | newProps: { 64 | content?: string; 65 | file_src?: string; 66 | expires_at?: string; 67 | expired?: boolean; 68 | } 69 | ) { 70 | const { content, expires_at, file_src, expired } = newProps; 71 | return this.dbAccess('todos') 72 | .update({ 73 | content, 74 | file_src, 75 | expires_at, 76 | expired, 77 | }) 78 | .where('id', todoId) 79 | .andWhere('user_id', userId) 80 | .returning('*'); 81 | } 82 | 83 | async removeTodo(todoId: string, userId: string) { 84 | return this.dbAccess('todos') 85 | .where('id', todoId) 86 | .andWhere('user_id', userId) 87 | .del(); 88 | } 89 | 90 | async setExpiredTodos() { 91 | return this.dbAccess('todos') 92 | .where('expires_at', '<', new Date()) 93 | .update({ expired: true }); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /llm-codegen/core/agents/troubleshooter.ts: -------------------------------------------------------------------------------- 1 | import { EXAMPLE_MODULE_FILE_PATHS } from '../constants'; 2 | import { BaseAgent } from './base'; 3 | 4 | /** 5 | * @class Troubleshooter 6 | * 7 | * The Troubleshooter micro-agent addresses TypeScript compilation errors within the code generation pipeline. 8 | * When the Orchestrator detects compilation failures, it triggers the Troubleshooter to analyze the errors, 9 | * generate corrective code using the LLM client, and apply the necessary fixes to ensure successful compilation. 10 | */ 11 | export class Troubleshooter extends BaseAgent { 12 | private generatedServices: string[]; 13 | private errorText: string = ''; 14 | 15 | constructor( 16 | projectDescription: string, 17 | moduleName: string, 18 | generatedServices: string[] 19 | ) { 20 | super('troubleshooter.main.prompt', projectDescription, moduleName); 21 | 22 | this.generatedServices = generatedServices; 23 | } 24 | 25 | setErrorText(errorText: string) { 26 | this.errorText = errorText; 27 | } 28 | 29 | async preparePrompt() { 30 | const generatedModuleServices = await Promise.all( 31 | this.generatedServices.map(async (serviceFileName) => { 32 | const fileContent = await this.loadSourceFile( 33 | `src/modules/${this.moduleName}/${serviceFileName}`, 34 | serviceFileName 35 | ); 36 | return `### SERVICE - ${serviceFileName.replace( 37 | '.service.ts', 38 | '' 39 | )}: \r\n ${fileContent}`; 40 | }) 41 | ); 42 | 43 | const filePathTemplateMapping = await this.loadSourceFileMap({ 44 | '{{MODULE_ROUTES}}': `src/modules/${this.moduleName}/routes.ts`, 45 | '{{MODULE_CONTROLLERS}}': `src/modules/${this.moduleName}/controllers.ts`, 46 | '{{MODULE_REPOSITORY}}': `src/modules/${this.moduleName}/repository.ts`, 47 | '{{MODULE_TYPES}}': `src/modules/${this.moduleName}/types.ts`, 48 | '{{MODULE_E2E_TESTS}}': `src/modules/${this.moduleName}/tests/api.spec.ts`, 49 | 50 | '{{SERVICES_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.UPDATE_TODO_SERVICE, 51 | '{{DI_CONFIG_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.DI_CONFIG, 52 | 53 | '{{ALL_API_ROUTES}}': EXAMPLE_MODULE_FILE_PATHS.ALL_API_ROUTES, 54 | '{{ALL_CONSTANTS}}': EXAMPLE_MODULE_FILE_PATHS.ALL_CONSTANTS, 55 | '{{ALL_DI_CONFIG}}': EXAMPLE_MODULE_FILE_PATHS.ALL_DI_CONFIG, 56 | }); 57 | 58 | this.templateMappings = { 59 | '{{PROJECT_DESCRIPTION}}': this.projectDescription, 60 | '{{MODULE_SERVICES}}': generatedModuleServices.join(`\r\n\r\n`), 61 | '{{ERROR_TEXT}}': this.errorText, 62 | ...filePathTemplateMapping, 63 | }; 64 | 65 | // call base implementation 66 | return super.preparePrompt(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/modules/todos/routes.ts: -------------------------------------------------------------------------------- 1 | import { todoController } from './controllers'; 2 | import { UserRoles } from '../../common/types'; 3 | import { isAuth, attachCurrentUser, checkRole } from '../../infra/middlewares'; 4 | 5 | /** 6 | * Represents a configuration for an Express.js route. 7 | * 8 | * @typedef {Object} RouteConfig 9 | * @property {string} method - The HTTP method (GET, POST, PUT, DELETE, etc.). 10 | * @property {string} path - The route path. 11 | * @property {function(Request, Response, NextFunction): void | Promise} handler - The route handler function. 12 | * @property {Array>} [middlewares] - An array of middleware functions for the route. 13 | */ 14 | 15 | /** 16 | * Represents a collection of Express.js routes. 17 | * 18 | * @typedef {Record} TodoRoutes 19 | */ 20 | 21 | /** 22 | * Sets up the TODO routes. 23 | * 24 | * @type {TodoRoutes} 25 | */ 26 | export const todoRoutes = { 27 | /** 28 | * Retrieves todos for the current authenticated user. 29 | * 30 | * @type {RouteConfig} 31 | */ 32 | getUserTodos: { 33 | method: 'GET', 34 | path: '/todos/my', 35 | handler: todoController.getUserTodos, 36 | middlewares: [isAuth, attachCurrentUser], 37 | }, 38 | /** 39 | * Retrieves specific todo by id. 40 | * 41 | * @type {RouteConfig} 42 | */ 43 | getTodoById: { 44 | method: 'GET', 45 | path: '/todos/:id', 46 | handler: todoController.getTodoById, 47 | middlewares: [isAuth, attachCurrentUser], 48 | }, 49 | 50 | /** 51 | * Retrieves all todos (Admin only). 52 | * 53 | * @type {RouteConfig} 54 | */ 55 | getAllTodos: { 56 | method: 'GET', 57 | path: '/todos/', 58 | handler: todoController.getAllTodos, 59 | middlewares: [isAuth, attachCurrentUser, checkRole(UserRoles.Admin)], 60 | }, 61 | 62 | /** 63 | * Adds new todos for the current authenticated user. 64 | * 65 | * @type {RouteConfig} 66 | */ 67 | addTodos: { 68 | method: 'POST', 69 | path: '/todos', 70 | handler: todoController.addTodos, 71 | middlewares: [isAuth, attachCurrentUser], 72 | }, 73 | 74 | /** 75 | * Updates a todo item for the current authenticated user. 76 | * 77 | * @type {RouteConfig} 78 | */ 79 | updateTodo: { 80 | method: 'PUT', 81 | path: '/todos/:id', 82 | handler: todoController.updateTodo, 83 | middlewares: [isAuth, attachCurrentUser], 84 | }, 85 | 86 | /** 87 | * Removes a todo item for the current authenticated user. 88 | * 89 | * @type {RouteConfig} 90 | */ 91 | removeTodo: { 92 | method: 'DELETE', 93 | path: '/todos/:id', 94 | handler: todoController.removeTodo, 95 | middlewares: [isAuth, attachCurrentUser], 96 | }, 97 | }; 98 | -------------------------------------------------------------------------------- /src/modules/users/registerAnonymousUser.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, injectable, toCamelCase, z } from '../../common/types'; 2 | import shortid from 'shortid'; 3 | import Operation from '../../common/operation'; 4 | import useTransaction from '../../common/useTransaction'; 5 | import useRateLimiter from '../../common/useRateLimiter'; 6 | import { hashPassword, generateJWT, generateRefreshToken } from './authUtils'; 7 | import { BINDINGS } from '../../common/constants'; 8 | import appConfig from '../../config/app'; 9 | import logger from '../../infra/loaders/logger'; 10 | import _ from 'lodash'; 11 | 12 | 13 | /** 14 | * @class RegisterAnonymousUser 15 | * 16 | * Handles the registration of anonymous users by creating a new user account with a generated username and password. 17 | * Generates JWT and refresh tokens, stores the user session in memory, and manages token expiration. 18 | */ 19 | @useTransaction() 20 | @useRateLimiter('CREATE_USER_PER_HOUR_BY_IP', { 21 | points: 5, // 5 calls 22 | duration: 60 * 60, // per 1 hour 23 | blockDuration: 60 * 60, // block on 1 hour 24 | }) 25 | @injectable() 26 | export class RegisterAnonymousUser extends Operation { 27 | static validationRules = z.object({ 28 | clientId: z.string().max(200).optional(), // Validates as an optional string 29 | }); 30 | 31 | @inject(BINDINGS.UsersRepository) 32 | private _usersRepository: any; 33 | 34 | async execute(validatedUserData: any) { 35 | const { clientId = appConfig.defaultClientId } = validatedUserData; 36 | 37 | const expires = new Date( 38 | Date.now() + appConfig.refreshTokenDuration * 1000 39 | ); 40 | 41 | logger.info('RegisterAnonymousUser validatedUserData=', validatedUserData); 42 | 43 | let user; 44 | const generated = shortid.generate(); 45 | const password = hashPassword(generated); 46 | const userName = `user_${generated}`; 47 | const refreshToken = generateRefreshToken(); 48 | 49 | try { 50 | user = await this._usersRepository.createUserWithToken( 51 | { 52 | userName, 53 | password, 54 | refreshToken, 55 | role: 'anonym', 56 | }, 57 | expires, 58 | clientId 59 | ); 60 | 61 | this._memoryStorage.setValue(user.id, user); 62 | 63 | logger.info('RegisterAnonymousUser created'); 64 | 65 | const jwt = generateJWT(user); 66 | 67 | return { user: _.omit(toCamelCase(user), 'password'), jwt, refreshToken }; 68 | } catch (error) { 69 | logger.error('RegisterAnonymousUser:error', error); 70 | 71 | if (typeof error === 'string') { 72 | throw new Error(error); 73 | } else if (error instanceof Error) { 74 | throw new Error(error.message); 75 | } else { 76 | throw new Error('An unexpected error occurred'); 77 | } 78 | } 79 | } 80 | } 81 | 82 | -------------------------------------------------------------------------------- /src/modules/users/loginUser.service.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { inject, injectable, toCamelCase, z } from '../../common/types'; 3 | import Operation from '../../common/operation'; 4 | import useRateLimiter from '../../common/useRateLimiter'; 5 | import { hashPassword, generateJWT, generateRefreshToken } from './authUtils'; 6 | import { BINDINGS } from '../../common/constants'; 7 | import logger from '../../infra/loaders/logger'; 8 | import appConfig from '../../config/app'; 9 | 10 | /** 11 | * @class LoginUser 12 | * 13 | * Handles the login operation for a user by validating credentials, 14 | * generating JWT and refresh tokens, and managing user sessions. 15 | */ 16 | @useRateLimiter('LOGIN_USER_PER_HOUR_BY_IP', { 17 | points: 5, // 5 calls 18 | duration: 60 * 60, // per 1 hour 19 | blockDuration: 60 * 60, // block on 1 hour 20 | }) 21 | @injectable() 22 | export class LoginUser extends Operation { 23 | static validationRules = z.object({ 24 | password: z.string().max(100).min(1), // Required string with a maximum length of 100 25 | username: z.string().max(100).optional(), // Optional string with a maximum length of 100 26 | email: z.string().max(100).optional(), // Optional string with a maximum length of 100 27 | }); 28 | 29 | @inject(BINDINGS.UsersRepository) 30 | private _usersRepository: any; 31 | 32 | async execute(validatedUserData: any) { 33 | let result = { jwt: null }; 34 | const { 35 | password, 36 | username: userName, 37 | email, 38 | clientId = appConfig.defaultClientId, 39 | } = validatedUserData; 40 | 41 | const expires = new Date( 42 | Date.now() + appConfig.refreshTokenDuration * 1000 43 | ); 44 | 45 | try { 46 | let user = userName 47 | ? await this._usersRepository.findByName(userName) 48 | : await this._usersRepository.findByEmail(email); 49 | 50 | if (user && hashPassword(password) === user.password) { 51 | const jwt = generateJWT(user); 52 | const refreshToken = generateRefreshToken(); 53 | 54 | await this._usersRepository.upsertUserRefreshToken( 55 | user.id, 56 | refreshToken, 57 | expires, 58 | clientId 59 | ); 60 | 61 | this._memoryStorage.setValue(user.id, user); 62 | 63 | return { 64 | user: _.omit(toCamelCase(user), 'password'), 65 | jwt, 66 | refreshToken, 67 | }; 68 | } 69 | } catch (error) { 70 | logger.error('LoginUser:error', error); 71 | 72 | if (typeof error === 'string') { 73 | throw new Error(error); 74 | } else if (error instanceof Error) { 75 | throw new Error(error.message); 76 | } else { 77 | throw new Error('An unexpected error occurred'); 78 | } 79 | } 80 | 81 | return result; 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /src/common/createServerController.ts: -------------------------------------------------------------------------------- 1 | import { container } from '../infra/loaders/diContainer'; 2 | import logger from '../infra/loaders/logger'; 3 | import { Knex, interfaces } from './types'; 4 | import Operation from './operation'; 5 | import { BINDINGS } from './constants'; 6 | import { stringifyError } from './utils'; 7 | 8 | export const createServerController = 9 | ( 10 | serviceConstructor: interfaces.Newable, 11 | paramsCb: Function = () => {}, 12 | parentTransaction?: Knex.Transaction 13 | ) => 14 | async (currentParams: any) => { 15 | logger.info('createServerController:start'); 16 | let transaction: Knex.Transaction | undefined; 17 | try { 18 | // 1. create transaction if needed, and share it between all repositories used by controller 19 | // create transaction if needed, and share it between all repositories used by controller 20 | if (!parentTransaction && serviceConstructor['useTransaction']) { 21 | logger.info(`createController:start:transaction`); 22 | 23 | const db: Knex = container.get(BINDINGS.DbAccess); 24 | transaction = await db.transaction(); 25 | } else if (parentTransaction) { 26 | logger.info(`createController:use:parent:transaction`); 27 | transaction = parentTransaction; 28 | } 29 | 30 | // 2. process use case service logic 31 | const service: Operation = container.resolve(serviceConstructor); 32 | 33 | // get all used repositories for controller 34 | const repositories = service.getRepositories(); 35 | 36 | // according to pattern "unit of work" perform all operation changes in transaction if needed 37 | // https://www.martinfowler.com/eaaCatalog/unitOfWork.html 38 | if (parentTransaction !== undefined) { 39 | repositories.forEach((repo) => repo.setDbAccess(parentTransaction!)); 40 | 41 | logger.info( 42 | `createServerController:repositories=${repositories.length}` 43 | ); 44 | } 45 | 46 | const params = paramsCb(currentParams); 47 | 48 | let result; 49 | 50 | if (params) { 51 | // run use case service 52 | result = await service.run(params); 53 | } else { 54 | logger.info( 55 | `createServerController:${serviceConstructor.name}:logic:warning:empty:params` 56 | ); 57 | result = { msg: 'SKIP_ALL_OTHER_SERVICES' }; 58 | } 59 | 60 | if (!parentTransaction && transaction) { 61 | transaction.commit(); 62 | } 63 | 64 | return { result }; 65 | } catch (ex) { 66 | logger.error( 67 | `createServerController:error:${stringifyError(ex)} \r\n ${ 68 | (ex as any).stack 69 | }` 70 | ); 71 | 72 | if (!parentTransaction && transaction) { 73 | transaction.rollback(); 74 | } 75 | 76 | throw ex; 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /src/modules/todos/controllers.ts: -------------------------------------------------------------------------------- 1 | import { createController } from '../../common/createController'; 2 | import { Request } from '../../common/types'; 3 | 4 | import { GetAllTodos } from './getAllTodos.service'; 5 | import { GetUserTodos } from './getUserTodos.service'; 6 | import { AddTodo } from './addTodo.service'; 7 | import { UpdateTodo } from './updateTodo.service'; 8 | import { RemoveTodo } from './removeTodo.service'; 9 | import { GetTodoById } from './getTodoById.service'; 10 | 11 | 12 | /** 13 | * @module TodosController 14 | * 15 | * Controller for handling Todo-related operations 16 | */ 17 | export const todoController = { 18 | /** 19 | * Retrieves all todos with optional search and pagination. 20 | * 21 | * @param {Request} req - The HTTP request object. 22 | * @returns {Promise>} A promise that resolves to an array of todos. 23 | */ 24 | getAllTodos: createController(GetAllTodos, (req: Request) => ({ 25 | search: req.query.search, 26 | pageSize: req.query.pageSize, 27 | pageInd: req.query.pageInd, 28 | })), 29 | 30 | /** 31 | * Retrieves specific todo by id. 32 | * 33 | * @param {Request} req - The HTTP request object. 34 | * @returns {Promise} A promise that resolves to todo object. 35 | */ 36 | getTodoById: createController(GetTodoById, (req: Request) => ({ 37 | todoId: req.params.id, 38 | })), 39 | 40 | /** 41 | * Retrieves todos for a specific user with optional search and pagination. 42 | * 43 | * @param {Request} req - The HTTP request object. 44 | * @returns {Promise>} A promise that resolves to an array of the user's todos. 45 | */ 46 | getUserTodos: createController(GetUserTodos, (req: Request) => ({ 47 | userId: req['currentUser'].id, 48 | search: req.query.search, 49 | pageSize: req.query.pageSize, 50 | pageInd: req.query.pageInd, 51 | })), 52 | 53 | /** 54 | * Adds new todos for the authenticated user. 55 | * 56 | * @param {Request} req - The HTTP request object. 57 | * @returns {Promise} A promise that resolves to the added todos. 58 | */ 59 | addTodos: createController(AddTodo, (req: Request) => ({ 60 | userId: req['currentUser'].id, 61 | content: req.body.content, 62 | fileSrc: req.body.fileSrc, 63 | expiresAt: req.body.expiresAt, 64 | })), 65 | 66 | /** 67 | * Updates the content of an existing todo. 68 | * 69 | * @param {Request} req - The HTTP request object. 70 | * @returns {Promise} A promise that resolves to the updated todo. 71 | */ 72 | updateTodo: createController(UpdateTodo, (req: Request) => ({ 73 | userId: req['currentUser'].id, 74 | todoId: req.params.id, 75 | content: req.body.content, 76 | fileSrc: req.body.fileSrc, 77 | expiresAt: req.body.expiresAt, 78 | })), 79 | 80 | /** 81 | * Removes a todo by its ID for the authenticated user. 82 | * 83 | * @param {Request} req - The HTTP request object. 84 | * @returns {Promise} A promise that resolves when the todo is removed. 85 | */ 86 | removeTodo: createController(RemoveTodo, (req: Request) => ({ 87 | userId: req['currentUser'].id, 88 | todoId: req.params.id, 89 | })), 90 | }; 91 | -------------------------------------------------------------------------------- /llm-codegen/core/agents/developer.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import { EXAMPLE_MODULE_FILE_PATHS } from '../constants'; 4 | import { BaseAgent } from './base'; 5 | 6 | const MISSING_FILES_INSTRUCTION = (missingFiles: string[]) => 7 | `- !IMPORTANT GENERATE only next missing files: ${missingFiles.join(',')}`; 8 | /** 9 | * @class Developer 10 | * 11 | * The Developer micro-agent is responsible for generating the majority of the codebase 12 | * within the code generation pipeline. It collaborates with the Orchestrator to create 13 | * necessary files and components (e.g., routes, controllers, services). The Orchestrator 14 | * oversees the workflow by checking for any missing files and, if detected, instructs the 15 | * Developer to regenerate the code with updated instructions to include the missing files. 16 | */ 17 | export class Developer extends BaseAgent { 18 | private missingFiles: string[] = []; 19 | 20 | constructor(projectDescription: string, moduleName: string) { 21 | super('developer.main.prompt', projectDescription, moduleName); 22 | } 23 | 24 | setMissingFiles(missingFiles: string[]) { 25 | this.missingFiles = missingFiles.slice(); 26 | } 27 | 28 | async preparePrompt() { 29 | const servicesExamples = await Promise.all( 30 | [ 31 | EXAMPLE_MODULE_FILE_PATHS.GET_USER_TODOS_SERVICE, 32 | EXAMPLE_MODULE_FILE_PATHS.GET_TODO_BY_ID_SERVICE, 33 | EXAMPLE_MODULE_FILE_PATHS.ADD_TODO_SERVICE, 34 | EXAMPLE_MODULE_FILE_PATHS.REMOVE_TODO_SERVICE, 35 | ].map(async (serviceFilePath) => { 36 | const fileContent = await this.loadSourceFile(serviceFilePath); 37 | const serviceFileName = path.basename(serviceFilePath); 38 | return `### SERVICE: - ${serviceFileName.replace( 39 | '.service.ts', 40 | '' 41 | )}: \r\n ${fileContent}`; 42 | }) 43 | ); 44 | 45 | const filePathTemplateMapping = await this.loadSourceFileMap({ 46 | '{{ROUTES_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.ROUTES, 47 | '{{CONTROLLERS_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.CONTROLLERS, 48 | '{{REPOSITORY_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.REPOSITORY, 49 | '{{DI_CONFIG_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.DI_CONFIG, 50 | '{{TYPES_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.TYPES, 51 | '{{E2E_TESTS_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.E2E_TESTS, 52 | '{{MIGRATIONS_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.MIGRATION, 53 | '{{ALL_API_ROUTES}}': EXAMPLE_MODULE_FILE_PATHS.ALL_API_ROUTES, 54 | '{{ALL_CONSTANTS}}': EXAMPLE_MODULE_FILE_PATHS.ALL_CONSTANTS, 55 | '{{ALL_DI_CONFIG}}': EXAMPLE_MODULE_FILE_PATHS.ALL_DI_CONFIG, 56 | '{{ALL_SEEDS}}': EXAMPLE_MODULE_FILE_PATHS.ALL_SEEDS, 57 | }); 58 | 59 | this.templateMappings = { 60 | '{{PROJECT_DESCRIPTION}}': this.projectDescription, 61 | '{{SERVICES_EXAMPLE}}': servicesExamples.join('\r\n\r\n'), 62 | '{{MISSING_FILES_INSTRUCTION}}': 63 | this.missingFiles.length > 0 64 | ? MISSING_FILES_INSTRUCTION(this.missingFiles) 65 | : '', 66 | ...filePathTemplateMapping, 67 | }; 68 | 69 | // call base implementation 70 | return super.preparePrompt(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /llm-codegen/core/agents/testsFixer.ts: -------------------------------------------------------------------------------- 1 | import { EXAMPLE_MODULE_FILE_PATHS } from '../constants'; 2 | import logger from '../logger'; 3 | import { BaseAgent } from './base'; 4 | 5 | /** 6 | * @class TestsFixer 7 | * 8 | * The TestsFixer micro-agent handles failed E2E tests within the code generation pipeline. 9 | * The Orchestrator detects test failures and triggers the TestsFixer to generate and apply fixes. 10 | */ 11 | export class TestsFixer extends BaseAgent { 12 | private generatedServices: string[]; 13 | private migrationPath: string; 14 | private errorText: string = ''; 15 | 16 | constructor( 17 | projectDescription: string, 18 | moduleName: string, 19 | generatedServices: string[], 20 | migrationPath: string 21 | ) { 22 | super('testsFixer.main.prompt', projectDescription, moduleName); 23 | 24 | this.moduleName = moduleName; 25 | this.projectDescription = projectDescription; 26 | this.generatedServices = generatedServices; 27 | this.migrationPath = migrationPath; 28 | } 29 | 30 | setErrorText(errorText: string) { 31 | this.errorText = errorText; 32 | } 33 | 34 | setMigrationPath(migrationPath: string) { 35 | this.migrationPath = migrationPath; 36 | } 37 | 38 | async preparePrompt() { 39 | logger.info( 40 | `${ 41 | this.constructor.name 42 | }:preparePrompt:this.generatedServices=${this.generatedServices.join( 43 | ',' 44 | )}` 45 | ); 46 | 47 | const generatedServicesExample = await Promise.all( 48 | this.generatedServices.map(async (serviceFileName) => { 49 | const fileContent = await this.loadSourceFile( 50 | `src/modules/${this.moduleName}/${serviceFileName}`, 51 | serviceFileName 52 | ); 53 | 54 | return `### SERVICE - ${serviceFileName.replace( 55 | '.service.ts', 56 | '' 57 | )}: \r\n ${fileContent}`; 58 | }) 59 | ); 60 | 61 | const exampleE2ETests = await this.loadSourceFile( 62 | EXAMPLE_MODULE_FILE_PATHS.E2E_TESTS 63 | ); 64 | const moduleE2ETests = await this.loadSourceFile( 65 | `src/modules/${this.moduleName}/tests/api.spec.ts` 66 | ); 67 | 68 | const e2eTestsContent = `EXAMPLE E2E_TESTS:\r\n${exampleE2ETests}\r\n 69 | GENERATED E2E_TESTS:\r\n${moduleE2ETests}`; 70 | 71 | const filePathTemplateMapping = await this.loadSourceFileMap({ 72 | '{{MODULE_ROUTES}}': `src/modules/${this.moduleName}/routes.ts`, 73 | '{{MODULE_CONTROLLERS}}': `src/modules/${this.moduleName}/controllers.ts`, 74 | '{{MODULE_REPOSITORY}}': `src/modules/${this.moduleName}/repository.ts`, 75 | '{{MODULE_MIGRATIONS}}': this.migrationPath, 76 | '{{SERVICES_EXAMPLE}}': EXAMPLE_MODULE_FILE_PATHS.UPDATE_TODO_SERVICE, 77 | '{{ALL_SEEDS}}': EXAMPLE_MODULE_FILE_PATHS.ALL_SEEDS, 78 | }); 79 | 80 | this.templateMappings = { 81 | '{{PROJECT_DESCRIPTION}}': this.projectDescription, 82 | '{{MODULE_SERVICES}}': generatedServicesExample.join('\r\n\r\n'), 83 | '{{MODULE_E2E_TESTS}}': e2eTestsContent, 84 | '{{ERROR_TEXT}}': this.errorText, 85 | ...filePathTemplateMapping, 86 | }; 87 | 88 | // call base implementation 89 | return super.preparePrompt(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | import _ from 'lodash'; 3 | import { fromZodError } from 'zod-validation-error'; 4 | import { UserRoles, HTTP_STATUS, Response, z } from './types'; 5 | import appConfig from '../config/app'; 6 | 7 | /** 8 | * Returns number - binary representation of user's role for passed string 9 | * Anonym - 1 - 001 10 | * Registered - 2 - 010 11 | * Admin - 4 - 100 12 | * @param role enum UserRoles 13 | */ 14 | const getRoleCode = (role: string): UserRoles => { 15 | const enumValue = role[0].toUpperCase() + role.slice(1); 16 | 17 | return UserRoles[enumValue]; 18 | } 19 | 20 | const getHashedValue = (text: string) => { 21 | return crypto 22 | .createHash('md5') 23 | .update(text + appConfig.hashSalt) 24 | .digest('hex'); 25 | } 26 | 27 | // success codes are 2xx 28 | const isErrorCode = (code: number) => !code.toString().startsWith('2'); 29 | const getStatusForError = (error) => { 30 | if (error.toString().toLowerCase().indexOf('validationerror') > -1) { 31 | return HTTP_STATUS.BAD_REQUEST; 32 | } 33 | 34 | return HTTP_STATUS.INTERNAL_SERVER_ERROR; 35 | }; 36 | 37 | const defaultResponseHandler = ( 38 | res: Response, 39 | { result, code, headers = [] }: { result: any; code: number; headers?: any[] } 40 | ) => { 41 | headers.forEach(({ name, value }) => { 42 | res.set(name, value); 43 | }); 44 | 45 | return res.status(code).json({ result }); 46 | }; 47 | 48 | const stringifyError = (error) => { 49 | return `name=${error.name}:message=${error.message}`; 50 | } 51 | 52 | /** 53 | * Recursively converts object keys from camelCase to snake_case. 54 | * 55 | * @param {Object|Array} data - The input data to convert. 56 | * @returns {Object|Array} - The converted data with snake_case keys. 57 | */ 58 | const camelToSnake = (data) => { 59 | if (_.isArray(data)) { 60 | return data.map(item => camelToSnake(item)); 61 | } else if (_.isObject(data) && !_.isDate(data) && !_.isRegExp(data)) { 62 | return _.transform(data, (result, value, key) => { 63 | const snakeKey = _.snakeCase(key); 64 | result[snakeKey] = camelToSnake(value); 65 | }, {}); 66 | } 67 | return data; 68 | } 69 | 70 | /** 71 | * Recursively converts object keys from snake_case to camelCase. 72 | * 73 | * @param {Object|Array} data - The input data to convert. 74 | * @returns {Object|Array} - The converted data with camelCase keys. 75 | */ 76 | const snakeToCamel = (data) => { 77 | if (_.isArray(data)) { 78 | return data.map(item => snakeToCamel(item)); 79 | } else if (_.isObject(data) && !_.isDate(data) && !_.isRegExp(data)) { 80 | return _.transform(data, (result, value, key) => { 81 | const camelKey = _.camelCase(key); 82 | result[camelKey] = snakeToCamel(value); 83 | }, {}); 84 | } 85 | return data; 86 | } 87 | 88 | const formatValidationError = (err: z.ZodError) => { 89 | const validationError = fromZodError(err); 90 | 91 | return validationError.toString(); 92 | } 93 | 94 | export { 95 | getRoleCode, 96 | getHashedValue, 97 | isErrorCode, 98 | getStatusForError, 99 | defaultResponseHandler, 100 | stringifyError, 101 | camelToSnake, 102 | snakeToCamel, 103 | formatValidationError, 104 | }; 105 | -------------------------------------------------------------------------------- /llm-codegen/core/prompts/developer.main.prompt: -------------------------------------------------------------------------------- 1 | # Node.js TypeScript API Module Generator 2 | 3 | You are a world-class full-stack software engineer. Generate a complete Node.js TypeScript API module following the established patterns and architecture. 4 | 5 | ## Project Requirements: 6 | {{PROJECT_DESCRIPTION}} 7 | 8 | ## Below are examples of how each layer is structured in our codebase. 9 | 10 | ### ROUTES: 11 | {{ROUTES_EXAMPLE}} 12 | 13 | ### CONTROLLERS: 14 | {{CONTROLLERS_EXAMPLE}} 15 | 16 | {{SERVICES_EXAMPLE}} 17 | 18 | ### REPOSITORY: 19 | {{REPOSITORY_EXAMPLE}} 20 | 21 | ### DI_CONFIG: 22 | {{DI_CONFIG_EXAMPLE}} 23 | 24 | ### TYPES: 25 | {{TYPES_EXAMPLE}} 26 | 27 | ### E2E_TESTS: 28 | {{E2E_TESTS_EXAMPLE}} 29 | 30 | ### MIGRATIONS: 31 | {{MIGRATIONS_EXAMPLE}} 32 | 33 | ## Output Requirements: 34 | 35 | You also need to output the updated apiRoutes file (### ALL_API_ROUTES) that includes routes initialization for the newly added module 36 | 37 | ### ALL_API_ROUTES 38 | {{ALL_API_ROUTES}} 39 | 40 | You also need to output the updated dependency injection file (### ALL_DI_CONFIG) that ONLY adds the bindings constants for all newly added code 41 | 42 | ### ALL_DI_CONFIG 43 | {{ALL_DI_CONFIG}} 44 | 45 | You also need to output the updated constants file (### ALL_CONSTANTS) that ONLY adds constants for all newly added code 46 | 47 | ### ALL_CONSTANTS 48 | {{ALL_CONSTANTS}} 49 | 50 | You also need to output extended DB seeds (### ALL_SEEDS) that include meaningful seed data for the new module. Ensure that all properties and attributes are realistic and contextually relevant to accurately represent real-world scenarios 51 | 52 | ### ALL_SEEDS 53 | {{ALL_SEEDS}} 54 | 55 | Based on the above examples, generate the corresponding code for the new module. Please structure your output using the following format: 56 | 57 | {{MISSING_FILES_INSTRUCTION}} 58 | 59 | - The generated code must follow the same structure and implementation patterns as in the provided examples. 60 | - Output code for each section: ROUTES, CONTROLLERS, REPOSITORY, DI_CONFIG, TYPES, E2E_TESTS, MIGRATIONS, each SERVICE, ALL_API_ROUTES, ALL_CONSTANTS and ALL_DI_CONFIG, and do not add any new sections. 61 | - Begin each section with `### ` followed by the section name in uppercase (e.g., `### TYPES:`). 62 | - Do not include numbering or extra text in section headers. 63 | - Provide only the code enclosed in triple backticks with the appropriate language identifier (e.g., ```typescript). 64 | - Do not include any explanatory text or file creation instructions. 65 | - When designing database entities, use simple and straightforward attributes that directly represent the real-world entity. 66 | - Generate code for all necessary service classes and output it under the appropriate section name - for example "### SERVICE - updateTodo". 67 | - When generating service input validation, use string types for date fields to allow dates to be passed as strings. 68 | - When generating seeds for ALL_SEEDS, keep the existing code in it and just add new seeds to it. 69 | - When generating E2E tests, ensure that the number of records used in your tests matches the number of records inserted by your generated ALL_SEEDS script to maintain consistency. 70 | - When generating service code, avoid using the spread operator `...` to extract data from the `validatedData` object. 71 | - When generating E2E tests, ensure they closely follow the patterns, structure, and conventions shown in the provided example E2E tests. 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /src/modules/users/routes.ts: -------------------------------------------------------------------------------- 1 | import { usersController } from './controllers'; 2 | import { UserRoles } from '../../common/types'; 3 | 4 | import { isAuth, attachCurrentUser, checkRole } from '../../infra/middlewares'; 5 | 6 | /** 7 | * @typedef {Object} RouteConfig 8 | * @property {'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD'} method - The HTTP method (GET, POST, PUT, DELETE, etc.). 9 | * @property {string} path - The route path (e.g., '/todos/my'). 10 | * @property {function(Request, Response, NextFunction): void | Promise} handler - The route handler function. 11 | * @property {Array>} [middlewares] - An array of middleware functions for the route. 12 | */ 13 | 14 | /** 15 | * Collection of user routes. 16 | * 17 | * @typedef {Object} UserRoutes 18 | * @property {RouteConfig} signup - Route for user signup. 19 | * @property {RouteConfig} signin - Route for user signin. 20 | * @property {RouteConfig} refreshToken - Route for refreshing JWT tokens. 21 | * @property {RouteConfig} signout - Route for user signout. 22 | * @property {RouteConfig} getUsers - Route for retrieving all users (admin only). 23 | * @property {RouteConfig} getUser - Route for retrieving the current authenticated user. 24 | */ 25 | 26 | /** 27 | * List of user routes. 28 | * 29 | * @type {UserRoutes} 30 | */ 31 | export const userRoutes = { 32 | /** 33 | * Route for user signup - register and return tokens for a new anonymous user. 34 | * 35 | * @type {RouteConfig} 36 | */ 37 | signup: { 38 | method: 'POST', 39 | path: '/signup', 40 | handler: usersController.registerAnonymous, 41 | // No middlewares for public signup 42 | }, 43 | 44 | /** 45 | * Route for user signin - authenticates a user and logs them in 46 | * 47 | * @type {RouteConfig} 48 | */ 49 | signin: { 50 | method: 'POST', 51 | path: '/signin', 52 | handler: usersController.loginUser, 53 | // No middlewares for public signin 54 | }, 55 | 56 | /** 57 | * Route for refreshing JWT tokens - refreshes JWT tokens for authenticated users 58 | * 59 | * @type {RouteConfig} 60 | */ 61 | refreshToken: { 62 | method: 'POST', 63 | path: '/jwt/refresh', 64 | handler: usersController.refreshToken, 65 | // No middlewares for public token refresh 66 | }, 67 | 68 | /** 69 | * Route for user signout - logs out the current authenticated user. 70 | * 71 | * @type {RouteConfig} 72 | */ 73 | signout: { 74 | method: 'POST', 75 | path: '/signout', 76 | handler: usersController.logoutUser, 77 | middlewares: [isAuth, attachCurrentUser], 78 | }, 79 | 80 | /** 81 | * Route for retrieving all users (admin only) - retrieves a list of all users. Accessible only by admins. 82 | * 83 | * @type {RouteConfig} 84 | */ 85 | getUsers: { 86 | method: 'GET', 87 | path: '/users/', 88 | handler: usersController.getUsers, 89 | middlewares: [isAuth, attachCurrentUser, checkRole(UserRoles.Admin)], 90 | }, 91 | 92 | /** 93 | * Route for retrieving info of the current user - retrieves the profile of the current authenticated user. 94 | * 95 | * @type {RouteConfig} 96 | */ 97 | getUser: { 98 | method: 'GET', 99 | path: '/users/me', 100 | handler: usersController.getUser, 101 | middlewares: [isAuth, attachCurrentUser], 102 | }, 103 | }; -------------------------------------------------------------------------------- /src/modules/users/repository.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuid } from 'uuid'; 2 | import { injectable } from '../../common/types'; 3 | import { UserInputData, User } from './types'; 4 | import BaseRepository from '../../common/baseRepository'; 5 | 6 | /** 7 | * @class UsersRepository 8 | * 9 | * Repository for managing user-related database operations, including retrieval, creation, updating, and deletion of users and their refresh tokens. 10 | */ 11 | @injectable() 12 | export class UsersRepository extends BaseRepository { 13 | async findAll() { 14 | return this.dbAccess!('users').returning('*'); 15 | } 16 | 17 | async findById(id: string): Promise { 18 | return this.dbAccess('users').where({ id }).first(); 19 | } 20 | 21 | async _findByCond(cond: object): Promise { 22 | return this.dbAccess('users') 23 | .leftJoin( 24 | 'user_refresh_tokens', 25 | 'user_refresh_tokens.user_id', 26 | 'users.id' 27 | ) 28 | .select( 29 | 'users.id', 30 | 'users.user_name', 31 | 'users.email', 32 | 'users.password', 33 | 'users.role', 34 | 'user_refresh_tokens.refresh_token' 35 | ) 36 | .where(cond) 37 | .first(); 38 | } 39 | 40 | async findByName(name: string): Promise { 41 | return this._findByCond({ user_name: name }); 42 | } 43 | 44 | async findByEmail(email: string): Promise { 45 | return this._findByCond({ email }); 46 | } 47 | 48 | async findByRefreshToken( 49 | refreshToken: string, 50 | clientId: number 51 | ): Promise { 52 | return this.dbAccess('users') 53 | .select('users.*') 54 | .innerJoin( 55 | 'user_refresh_tokens', 56 | 'user_refresh_tokens.user_id', 57 | 'users.id' 58 | ) 59 | .where('user_refresh_tokens.refresh_token', refreshToken) 60 | .andWhere('user_refresh_tokens.client_id', clientId) 61 | .first(); 62 | } 63 | 64 | async createUserWithToken( 65 | userData: UserInputData, 66 | expires: Date, 67 | clientId: number 68 | ): Promise { 69 | const [newUser] = await this.dbAccess('users') 70 | .insert([ 71 | { 72 | id: uuid(), 73 | user_name: userData.userName, 74 | email: userData.email, 75 | password: userData.password, 76 | role: userData.role, 77 | }, 78 | ]) 79 | .returning('*'); 80 | 81 | await this.dbAccess!('user_refresh_tokens').insert([ 82 | { 83 | id: uuid(), 84 | user_id: newUser.id, 85 | client_id: clientId, 86 | refresh_token: userData.refreshToken, 87 | expires, 88 | }, 89 | ]); 90 | 91 | return newUser; 92 | } 93 | 94 | async upsertUserRefreshToken( 95 | userId: string, 96 | refreshToken: string, 97 | expires: Date, 98 | clientId: number 99 | ) { 100 | await this.delRefreshTokenForUser(userId); 101 | await this.dbAccess!('user_refresh_tokens').insert([ 102 | { 103 | id: uuid(), 104 | user_id: userId, 105 | client_id: clientId, 106 | refresh_token: refreshToken, 107 | expires, 108 | }, 109 | ]); 110 | } 111 | 112 | async delRefreshTokenForUser(userId: string) { 113 | return this.dbAccess!('user_refresh_tokens').where('user_id', userId).del(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/modules/users/tests/api.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | import app from '../../../app'; 3 | import { HTTP_STATUS } from '../../../common/types'; 4 | import { addHeaders } from '../../../tests/utils'; 5 | 6 | // TODO: Cleanup created users after tests 7 | describe('Users API', () => { 8 | const request = supertest(app); 9 | 10 | let jwtToken; 11 | let refreshToken; 12 | let adminJwtToken; 13 | 14 | it('Should register anonymous user', async () => { 15 | const response = await addHeaders(request.post('/api/signup')); 16 | expect(response.status).toBe(HTTP_STATUS.CREATED); 17 | expect(response.body.result).toEqual(expect.anything()); 18 | expect(response.body.result.jwt).toEqual(expect.anything()); 19 | expect(response.body.result.user).toEqual(expect.anything()); 20 | 21 | expect(response.body.result.user.userName).toMatch(/user_/i); 22 | expect(response.body.result.refreshToken).toEqual(expect.anything()); 23 | 24 | // store jwt and refresh tokens 25 | jwtToken = response.body.result.jwt; 26 | refreshToken = response.body.result.refreshToken; 27 | }); 28 | 29 | it('Should correctly load profile data for registered data', async () => { 30 | const response = await addHeaders(request.get('/api/users/me'), jwtToken); 31 | expect(response.status).toBe(HTTP_STATUS.OK); 32 | // expect(response.body.result).not.toBe(null); 33 | expect(response.body.result).toEqual(expect.anything()); 34 | expect(response.body.result.id).toEqual(expect.anything()); 35 | expect(response.body.result.userName).toMatch(/user_/i); 36 | }); 37 | 38 | it('Should login admin user', async () => { 39 | const response = await addHeaders( 40 | request.post('/api/signin').send({ 41 | username: 'admin', 42 | password: '123456', 43 | }) 44 | ); 45 | 46 | expect(response.status).toBe(HTTP_STATUS.OK); 47 | expect(response.body.result).toEqual(expect.anything()); 48 | expect(response.body.result.jwt).toEqual(expect.anything()); 49 | expect(response.body.result.user).toEqual(expect.anything()); 50 | expect(response.body.result.user.userName).toMatch('admin'); 51 | 52 | adminJwtToken = response.body.result.jwt; 53 | }); 54 | 55 | it('Should refresh JWT for registered user', async () => { 56 | const response = await addHeaders( 57 | request.post('/api/jwt/refresh').send({ 58 | refreshToken, 59 | }) 60 | ); 61 | 62 | expect(response.status).toBe(HTTP_STATUS.OK); 63 | expect(response.body.result).toEqual(expect.anything()); 64 | expect(response.body.result.jwt).toEqual(expect.anything()); 65 | expect(response.body.result.user).toEqual(expect.anything()); 66 | expect(response.body.result.user.userName).toMatch(/user_/i); 67 | }); 68 | 69 | it('Should not load all users for anonymous user', async () => { 70 | const response = await addHeaders(request.get('/api/users'), jwtToken); 71 | 72 | expect(response.status).toBe(HTTP_STATUS.UNAUTHORIZED); 73 | }); 74 | 75 | it('[ADMIN] Should load all users for admin user', async () => { 76 | const response = await addHeaders(request.get('/api/users'), adminJwtToken); 77 | 78 | expect(response.status).toBe(HTTP_STATUS.OK); 79 | 80 | expect(response.body.result).toEqual(expect.anything()); 81 | expect(response.body.result.length).toBeGreaterThan(1); 82 | }); 83 | 84 | it('Should logout admin user', async () => { 85 | const response = await addHeaders( 86 | request.post('/api/signout'), 87 | adminJwtToken 88 | ); 89 | 90 | expect(response.status).toBe(HTTP_STATUS.OK); 91 | expect(response.body.result).toEqual(expect.anything()); 92 | expect(response.body.result.deletedTokensCount).toEqual(1); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodejs-api-boilerplate", 3 | "version": "1.0.0", 4 | "description": "Node.js and TypeScript REST API boilerplate", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "NODE_ENV=prod node dist/server.js", 8 | "build": "tsc -p .", 9 | "dev": "NODE_ENV=dev ts-node-dev --respawn --ignore-watch node_modules src/server.ts", 10 | "migrate:latest": "npx knex migrate:latest --knexfile ./src/config/knexfile.ts", 11 | "migrate:make": "npx knex --knexfile ./src/config/knexfile.ts migrate:make -x ts", 12 | "migrate:rollback": "npx knex migrate:down --knexfile ./src/config/knexfile.ts", 13 | "migrate:prod": "npx knex migrate:latest --knexfile ./dist/config/knexfile.js", 14 | "seed": "npx knex seed:run --knexfile ./src/config/knexfile.ts", 15 | "seed:prod": "npx knex seed:run --knexfile ./dist/config/knexfile.js", 16 | "docker:run": "docker-compose -f docker-compose.dev.yml --project-name=app up", 17 | "docker:build": "docker-compose -f docker-compose.dev.yml down && docker-compose -f docker-compose.dev.yml --project-name=app up -d --no-deps --build", 18 | "test": "NODE_ENV=test node --trace-deprecation node_modules/jest/bin/jest --forceExit --detectOpenHandles --verbose --runInBand", 19 | "local:test": "rm -f ./test.sqlite3 && USE_SQLITE_DB=true NODE_ENV=test DEBUG=false npm run migrate:latest && USE_SQLITE_DB=true NODE_ENV=test SKIP_IF_ALREADY_RUN=true DEBUG=false npm run seed && USE_SQLITE_DB=true NODE_ENV=test node --trace-deprecation node_modules/jest/bin/jest --forceExit --detectOpenHandles --verbose --runInBand", 20 | "docker:test": "docker-compose -f docker-compose.test.yml --project-name=app-tests up", 21 | "docker:test:build": "docker-compose -f docker-compose.test.yml down && docker-compose -f docker-compose.test.yml --project-name=app-tests up -d --no-deps --build", 22 | "generate:docs": "typedoc", 23 | "serve:docs": "npx http-server docs" 24 | }, 25 | "keywords": [ 26 | "nodejs", 27 | "typescript", 28 | "boilerplate", 29 | "knex", 30 | "express", 31 | "postgres", 32 | "inversifyjs" 33 | ], 34 | "author": "v.yancharuk@gmail.com", 35 | "license": "MIT", 36 | "devDependencies": { 37 | "@types/aws-sdk": "^2.7.0", 38 | "@types/cors": "^2.8.17", 39 | "@types/express": "^4.17.21", 40 | "@types/inversify": "^2.0.33", 41 | "@types/ioredis": "^4.28.10", 42 | "@types/jest": "^29.5.12", 43 | "@types/joi": "^17.2.2", 44 | "@types/knex": "^0.16.1", 45 | "@types/node": "^20.14.5", 46 | "@types/reflect-metadata": "^0.1.0", 47 | "@types/shortid": "0.0.32", 48 | "@types/supertest": "^6.0.2", 49 | "@types/uuid": "^9.0.8", 50 | "@types/winston": "^2.4.4", 51 | "http-server": "^14.1.1", 52 | "install": "^0.13.0", 53 | "jest": "^29.7.0", 54 | "npm": "^10.8.1", 55 | "reflect-metadata": "^0.2.2", 56 | "supertest": "^7.0.0", 57 | "ts-jest": "^29.1.5", 58 | "ts-node": "^10.9.2", 59 | "ts-node-dev": "^2.0.0", 60 | "typedoc": "^0.26.9", 61 | "typescript": "^5.4.5" 62 | }, 63 | "dependencies": { 64 | "aws-sdk": "^2.1643.0", 65 | "camelcase-keys": "^6.2.1", 66 | "cls-rtracer": "^2.6.3", 67 | "compression": "^1.8.1", 68 | "cors": "^2.8.5", 69 | "dotenv": "^16.4.5", 70 | "express": "^4.22.0", 71 | "express-jwt": "^8.4.1", 72 | "helmet": "^7.1.0", 73 | "http-status": "^1.7.4", 74 | "inversify": "^6.0.2", 75 | "ioredis": "^5.4.1", 76 | "jsonwebtoken": "^9.0.2", 77 | "knex": "^2.5.1", 78 | "lodash": "^4.17.21", 79 | "morgan": "^1.10.1", 80 | "nanoid": "^3.3.8", 81 | "node-cron": "^3.0.3", 82 | "pg": "^8.12.0", 83 | "rate-limiter-flexible": "^5.0.3", 84 | "redis": "^4.6.14", 85 | "request-ip": "^3.3.0", 86 | "shortid": "^2.2.15", 87 | "sqlite3": "^5.1.7", 88 | "to-readable-stream": "^4.0.0", 89 | "uuid": "^10.0.0", 90 | "winston": "^3.13.0", 91 | "zod": "^3.23.8", 92 | "zod-validation-error": "^3.4.0" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/modules/users/controllers.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, HTTP_STATUS } from '../../common/types'; 2 | import { createController } from '../../common/createController'; 3 | 4 | import { GetUser } from './getUser.service'; 5 | import { GetUsers } from './getUsers.service'; 6 | import { LoginUser } from './loginUser.service'; 7 | import { LogoutUser } from './logoutUser.service'; 8 | import { RegisterAnonymousUser } from './registerAnonymousUser.service'; 9 | import { RefreshToken } from './refreshToken.service'; 10 | 11 | import { isErrorCode } from '../../common/utils'; 12 | 13 | /** 14 | * Controller module for handling user-related operations. 15 | * @module controllers/users 16 | */ 17 | export const usersController = { 18 | /** 19 | * Controller to register an anonymous user. 20 | * @property {Function} registerAnonymous 21 | * @param {Request} req - The Express request object containing user data. 22 | * @param {Response} res - The Express response object. 23 | * @returns {Function} - The controller function that handles anonymous user registration. 24 | */ 25 | registerAnonymous: createController( 26 | RegisterAnonymousUser, 27 | async (req: Request, res: Response) => req.body, 28 | (res: Response, { result, code, headers = [] }: any, req: Request) => { 29 | headers.forEach(({ name, value }) => { 30 | res.set(name, value); 31 | }); 32 | 33 | if (isErrorCode(code)) { 34 | return res.status(code).json({ 35 | result, 36 | }); 37 | } 38 | 39 | // handle output statuses 40 | if (!result || !result.jwt) { 41 | return res.status(HTTP_STATUS.UNAUTHORIZED).json({ result }); 42 | } 43 | 44 | return res.status(HTTP_STATUS.CREATED).json({ 45 | result, 46 | }); 47 | } 48 | ), 49 | 50 | /** 51 | * Controller to log in a user. 52 | * @property {Function} loginUser 53 | * @param {Request} req - The Express request object containing user credentials. 54 | * @returns {Function} - The controller function that handles user login. 55 | */ 56 | loginUser: createController( 57 | LoginUser, 58 | (req: Request) => req.body, 59 | (res: Response, { result, code, headers = [] }: any) => { 60 | headers.forEach(({ name, value }) => { 61 | res.set(name, value); 62 | }); 63 | 64 | if (isErrorCode(code)) { 65 | return res.status(code).json({ 66 | result, 67 | }); 68 | } 69 | 70 | // handle output statuses 71 | if (!result || !result.jwt) { 72 | return res.status(HTTP_STATUS.UNAUTHORIZED).json({ result }); 73 | } 74 | 75 | res.json({ result }).status(HTTP_STATUS.OK); 76 | } 77 | ), 78 | 79 | /** 80 | * Controller to log out a user. 81 | * @property {Function} logoutUser 82 | * @param {Request} req - The Express request object containing authorization headers. 83 | * @returns {Function} - The controller function that handles user logout. 84 | */ 85 | logoutUser: createController( 86 | LogoutUser, 87 | (req: Request) => ({ 88 | jwt: req.headers['authorization']!.split(' ')[1], 89 | userId: req['currentUser'] ? req['currentUser'].id : '', 90 | }), 91 | (res: Response, { result, code }: any) => { 92 | res.json({ result }).status(code); 93 | } 94 | ), 95 | /** 96 | * Controller to refresh the user's authentication token. 97 | * @property {Function} refreshToken 98 | * @param {Request} req - The Express request object containing the refresh token. 99 | * @returns {Function} - The controller function that handles token refresh. 100 | */ 101 | refreshToken: createController(RefreshToken, (req: Request) => req.body), 102 | 103 | /** 104 | * Controller to get information about the current authenticated user. 105 | * @property {Function} getUser 106 | * @param {Request} req - The Express request object containing the current user. 107 | * @returns {Function} - The controller function that retrieves user information. 108 | */ 109 | getUser: createController(GetUser, (req: Request) => ({ 110 | userId: req['currentUser'].id, 111 | })), 112 | 113 | 114 | /** 115 | * Controller to get a list of users. 116 | * @property {Function} getUsers 117 | * @returns {Function} - The controller function that retrieves a list of users. 118 | */ 119 | getUsers: createController(GetUsers, (req: Request) => ({ 120 | userId: req['currentUser'].id, 121 | })), 122 | }; 123 | -------------------------------------------------------------------------------- /src/modules/todos/tests/api.spec.ts: -------------------------------------------------------------------------------- 1 | import supertest from 'supertest'; 2 | 3 | import app from '../../../app'; 4 | import { HTTP_STATUS } from '../../../common/types'; 5 | import { addHeaders } from '../../../tests/utils'; 6 | 7 | describe('Todos API', () => { 8 | const request = supertest(app); 9 | 10 | let existingTodoUuid; 11 | 12 | beforeAll(async () => { 13 | const { ADMIN_JWT_TOKEN: jwtToken } = process.env; 14 | 15 | // Fetch existing todos 16 | const response = await addHeaders(request.get('/api/todos/my'), jwtToken); 17 | 18 | expect(response.status).toBe(HTTP_STATUS.OK); 19 | 20 | const todos = response.body.result; 21 | expect(Array.isArray(todos)).toBe(true); 22 | expect(todos.length).toBeGreaterThan(0); 23 | 24 | // Store the UUID of the first todo 25 | existingTodoUuid = todos[0].id; 26 | }); 27 | 28 | it('Should correctly load user todos', async () => { 29 | const { ADMIN_JWT_TOKEN: jwtToken } = process.env; 30 | 31 | const response = await addHeaders(request.get('/api/todos/my'), jwtToken); 32 | 33 | expect(response.status).toBe(HTTP_STATUS.OK); 34 | expect(response.body.result).toEqual(expect.anything()); 35 | expect(response.body.result.length).toBe(5); 36 | }); 37 | 38 | it('Should correctly get todo by id', async () => { 39 | const { ADMIN_JWT_TOKEN: jwtToken } = process.env; 40 | const response = await addHeaders( 41 | request.get(`/api/todos/${existingTodoUuid}`), 42 | jwtToken 43 | ); 44 | 45 | expect(response.status).toBe(HTTP_STATUS.OK); 46 | }); 47 | 48 | it('Should handle non-existent todo id', async () => { 49 | const { ADMIN_JWT_TOKEN: jwtToken } = process.env; 50 | const response = await addHeaders( 51 | request.get(`/api/todos/00000000-0000-0000-0000-000000000000`), 52 | jwtToken 53 | ); 54 | 55 | expect(response.status).toBe(HTTP_STATUS.NOT_FOUND); 56 | expect(response.body.result.message).toBe('Todo not found'); 57 | }); 58 | 59 | it('Should correctly load all todos', async () => { 60 | const { ADMIN_JWT_TOKEN: jwtToken } = process.env; 61 | 62 | const response = await addHeaders(request.get('/api/todos'), jwtToken); 63 | 64 | expect(response.status).toBe(HTTP_STATUS.OK); 65 | expect(response.body.result).toEqual(expect.anything()); 66 | expect(response.body.result.length).toBe(5); 67 | }); 68 | 69 | it('Should correctly add new todo', async () => { 70 | const { ADMIN_JWT_TOKEN: jwtToken } = process.env; 71 | 72 | const response = await addHeaders( 73 | request.post('/api/todos').send({ 74 | content: 'Complete test tasks', 75 | fileSrc: 'https://s3.test.com/todo_logo.png', 76 | expires_at: new Date(2025, 1, 1), 77 | }), 78 | jwtToken 79 | ); 80 | 81 | expect(response.status).toBe(HTTP_STATUS.OK); 82 | expect(response.body.result).toEqual(expect.anything()); 83 | expect(response.body.result.content).toEqual('Complete test tasks'); 84 | expect(response.body.result.fileSrc).toEqual('https://s3.test.com/todo_logo.png'); 85 | }); 86 | 87 | 88 | it('Should NOT allow add new todo with invalid input', async () => { 89 | const { ADMIN_JWT_TOKEN: jwtToken } = process.env; 90 | 91 | const response = await addHeaders( 92 | request.post('/api/todos').send({ 93 | // send 1 symbol 94 | content: 'C', 95 | }), 96 | jwtToken 97 | ); 98 | 99 | expect(response.status).toBe(HTTP_STATUS.BAD_REQUEST); 100 | }); 101 | 102 | it('Should correctly update existing todo', async () => { 103 | const { ADMIN_JWT_TOKEN: jwtToken } = process.env; 104 | 105 | const response = await addHeaders( 106 | request.put(`/api/todos/${existingTodoUuid}`).send({ 107 | content: 'Complete tasks again', 108 | fileSrc: 'https://s3.test.com/todo_new_logo.png', 109 | }), 110 | jwtToken 111 | ); 112 | 113 | expect(response.status).toBe(HTTP_STATUS.OK); 114 | expect(response.body.result).toEqual(expect.anything()); 115 | expect(response.body.result.content).toEqual('Complete tasks again'); 116 | expect(response.body.result.fileSrc).toEqual('https://s3.test.com/todo_new_logo.png'); 117 | }); 118 | 119 | it('Should correctly delete existing todo', async () => { 120 | const { ADMIN_JWT_TOKEN: jwtToken } = process.env; 121 | 122 | const response = await addHeaders( 123 | request.delete(`/api/todos/${existingTodoUuid}`), 124 | jwtToken 125 | ); 126 | 127 | expect(response.status).toBe(HTTP_STATUS.OK); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 6 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 7 | // "lib": [], /* Specify library files to be included in the compilation. */ 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 12 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 13 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 14 | // "outFile": "./", /* Concatenate and emit output to single file. */ 15 | "outDir": "./dist" /* Redirect output structure to the directory. */, 16 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 17 | // "composite": true, /* Enable project compilation */ 18 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | 25 | /* Strict Type-Checking Options */ 26 | "strict": true /* Enable all strict type-checking options. */, 27 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, 28 | // "strictNullChecks": true, /* Enable strict null checks. */ 29 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 30 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 31 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 32 | "noImplicitThis": false /* Raise error on 'this' expressions with an implied 'any' type. */, 33 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 34 | 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | 41 | /* Module Resolution Options */ 42 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 43 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 44 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 45 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 46 | // "typeRoots": [], /* List of folders to include type definitions from. */ 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 52 | 53 | /* Source Map Options */ 54 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 55 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 56 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 57 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 58 | 59 | /* Experimental Options */ 60 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 61 | "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 62 | 63 | "skipLibCheck": true, 64 | 65 | /* Advanced Options */ 66 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 67 | }, 68 | "include": ["src/**/*.ts"], // Adjust based on your project structure 69 | "exclude": ["node_modules", "dist", "tests", "**/*.spec.ts"] 70 | } 71 | -------------------------------------------------------------------------------- /src/common/createController.ts: -------------------------------------------------------------------------------- 1 | import { RateLimiterRedis } from 'rate-limiter-flexible'; 2 | import requestIp from 'request-ip'; 3 | 4 | import { container } from '../infra/loaders/diContainer'; 5 | import logger from '../infra/loaders/logger'; 6 | import { BINDINGS } from './constants'; 7 | import Operation from './operation'; 8 | import { 9 | HTTP_STATUS, 10 | interfaces, 11 | Knex, 12 | NextFunction, 13 | Request, 14 | Response, 15 | z, 16 | } from './types'; 17 | import { 18 | defaultResponseHandler, 19 | getStatusForError, 20 | formatValidationError, 21 | } from './utils'; 22 | 23 | /** 24 | * Creates an express.js controller function for handling API requests. 25 | * This function adheres to Clean Architecture principles, where `paramsCb` serves as an interactor 26 | * to pass parameters to the service (use case). 27 | * 28 | * @param {interfaces.Newable} serviceConstructor - The constructor of the service (operation) to be executed. 29 | * @param {Function} [paramsCb=() => {}] - A callback function to extract and transform parameters from the request. According to clean architecture paramsCb serves as interactor - it passes params to service(use case) 30 | * @param {Function} [resCb=defaultResponseHandler] - A callback function to handle the response. 31 | * @param {Knex.Transaction} [parentTransaction] - An optional parent transaction to be used for database operations. 32 | * 33 | * @returns {Function} An Express.js middleware function that processes the request, executes the service, and handles the response. 34 | * 35 | * @throws {Error} Throws an error if JWT is already expired, rate limits are exceeded, or any unexpected error occurs during the operation. 36 | */ 37 | export const createController = 38 | ( 39 | serviceConstructor: interfaces.Newable, 40 | paramsCb: Function = () => {}, 41 | resCb: Function = defaultResponseHandler, 42 | parentTransaction?: Knex.Transaction | undefined 43 | ) => 44 | async (req: Request, res: Response, next: NextFunction) => { 45 | // 1. check jwt token if it is already expired 46 | logger.info( 47 | `createController:start auth=${!!req.headers['authorization']}` 48 | ); 49 | 50 | let transaction: Knex.Transaction | undefined; 51 | try { 52 | if (req.headers['authorization']) { 53 | const memoryStorage: any = container.get(BINDINGS.MemoryStorage); 54 | // check for already expired token 55 | const jwt = req.headers['authorization']!.split(' ')[1]; 56 | const jwtSign = jwt.split('.')[2]; 57 | const tokenExpired = await memoryStorage.getValue(jwtSign); 58 | 59 | logger.info( 60 | `createController:check expired jwtSign:${jwtSign} tokenExpired=${tokenExpired}` 61 | ); 62 | 63 | if (tokenExpired) { 64 | return resCb(res, { 65 | result: { error: 'JWT_ALREADY_EXPIRED' }, 66 | code: HTTP_STATUS.BAD_REQUEST, 67 | }); 68 | } 69 | } 70 | 71 | // 2. process rate limiters 72 | const ipAddr = requestIp.getClientIp(req); 73 | 74 | const { retrySecs, currentRateLimiters } = await processRateLimiters( 75 | ipAddr, 76 | serviceConstructor['rateLimiters'] 77 | ? serviceConstructor['rateLimiters'] 78 | : [] 79 | ); 80 | 81 | if (retrySecs > 0) { 82 | return resCb(res, { 83 | result: { error: 'TOO_MANY_REQUESTS' }, 84 | code: HTTP_STATUS.TOO_MANY_REQUESTS, 85 | headers: [{ name: 'Retry-After', value: `${String(retrySecs)}sec` }], 86 | }); 87 | } 88 | 89 | logger.info( 90 | `createController:before consume rate limiters count=${currentRateLimiters.length}` 91 | ); 92 | 93 | // update rateLimiters 94 | await Promise.all(currentRateLimiters.map((tr) => tr.consume(ipAddr))); 95 | 96 | // 3. process use case service logic 97 | logger.info(`createController:init`); 98 | 99 | // create transaction if needed, and share it between all repositories used by controller 100 | if (!parentTransaction && serviceConstructor['useTransaction']) { 101 | logger.info(`createController:start:transaction`); 102 | 103 | const db: Knex = container.get(BINDINGS.DbAccess); 104 | transaction = await db.transaction(); 105 | } else if (parentTransaction) { 106 | logger.info(`createController:use:parent:transaction`); 107 | transaction = parentTransaction; 108 | } 109 | 110 | logger.info(`createController:use transaction=${!!transaction}`); 111 | 112 | const service: Operation = container.resolve(serviceConstructor); 113 | 114 | // according to pattern "unit of work" perform all operation changes in transaction if needed 115 | // https://www.martinfowler.com/eaaCatalog/unitOfWork.html 116 | if (transaction !== undefined) { 117 | // get all used repositories for controller 118 | const repositories = service.getRepositories(); 119 | repositories.forEach((repo) => repo.setDbAccess(transaction!)); 120 | 121 | logger.info(`createController:repositories=${repositories.length}`); 122 | } 123 | 124 | const params = await paramsCb(req, res); 125 | const result = await service.run(params); 126 | 127 | if (!parentTransaction && transaction !== undefined) { 128 | logger.info(`createController:transaction commit`); 129 | 130 | await transaction.commit(); 131 | } 132 | 133 | logger.info(`createController:completed`); 134 | 135 | if (!resCb) { 136 | return res.json({ result }).status(HTTP_STATUS.OK); 137 | } 138 | 139 | return resCb(res, { result, code: HTTP_STATUS.OK }, req); 140 | } catch (ex) { 141 | if (!parentTransaction && transaction !== undefined) { 142 | logger.warn(`createController:transaction rollback`); 143 | 144 | await transaction.rollback(); 145 | } else if (parentTransaction) { 146 | logger.warn(`createController:skip parentTransaction rollback`); 147 | } 148 | 149 | if (ex instanceof z.ZodError) { 150 | logger.error(`createController:error=${formatValidationError(ex)}`); 151 | return resCb(res, { 152 | result: formatValidationError(ex), 153 | code: HTTP_STATUS.BAD_REQUEST, 154 | message: ex.message, 155 | }); 156 | } 157 | if ( 158 | !(ex instanceof Error) && 159 | (ex as { msBeforeNext: number }).msBeforeNext 160 | ) { 161 | logger.error( 162 | `createController:error:TOO_MANY_REQUESTS:=${JSON.stringify(ex)}` 163 | ); 164 | return resCb(res, { 165 | result: { error: 'TOO_MANY_REQUESTS' }, 166 | code: HTTP_STATUS.TOO_MANY_REQUESTS, 167 | headers: [ 168 | { 169 | name: 'Retry-After', 170 | value: `${ 171 | String(Math.round((ex as any).msBeforeNext / 1000)) || '1' 172 | }sec`, 173 | }, 174 | ], 175 | }); 176 | } 177 | logger.error(`createController:error ${ex} \r\n ${(ex as any).stack}`); 178 | 179 | return resCb(res, { 180 | result: { error: (ex as any).toString(), message: (ex as any).message }, 181 | code: (ex as any).status || getStatusForError(ex), 182 | }); 183 | } 184 | }; 185 | 186 | const processRateLimiters = async (ipAddr, rateLimiters: any[]) => { 187 | const redis = container.get(BINDINGS.Redis); 188 | 189 | // create rate limiters if needed for current class 190 | const currentRateLimiters: any[] = rateLimiters.reduce((result, params) => { 191 | let rt; 192 | if (container.isBound(params.keyPrefix)) { 193 | rt = container.get(params.keyPrefix); 194 | } else { 195 | rt = new RateLimiterRedis({ 196 | storeClient: redis, 197 | ...params, 198 | }); 199 | 200 | container.bind(params.keyPrefix).toConstantValue(rt); 201 | } 202 | 203 | return result.concat([rt]); 204 | }, []); 205 | let retrySecs = 0; 206 | 207 | const rtResults = await Promise.all( 208 | currentRateLimiters.map((tr) => tr.get(ipAddr)) 209 | ); 210 | 211 | rtResults.forEach((resByIP, ind) => { 212 | // check if IP address is already blocked 213 | if ( 214 | retrySecs === 0 && 215 | resByIP !== null && 216 | resByIP.consumedPoints > rateLimiters[ind].points 217 | ) { 218 | retrySecs = Math.round(resByIP.msBeforeNext / 1000) || 1; 219 | } 220 | }); 221 | 222 | return { 223 | retrySecs, 224 | currentRateLimiters, 225 | }; 226 | }; 227 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🛠️ LLM-Powered Node.js CRUD API template 2 | 3 |

4 | LLM-Powered Node.js CRUD API template 5 |

6 | 7 | ![node](https://img.shields.io/badge/node-v14.21.3--v20.15.1-brightgreen) ![npm](https://img.shields.io/badge/npm-v6.14.18-blue) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/firstcontributions/first-contributions#first-contributions) 8 | 9 | ## Description 10 | An LLM-powered code generation tool that relies on the built-in [Node.js API Typescript Template Project](#nodejs-api-typescript-template-project) to easily generate clean, well-structured CRUD module code from text description. 11 | 12 | 13 | ### Prerequisites 14 | Before you start, make sure Node.js and npm are installed and obtain a valid LLM provider API key. You may choose from providers like OpenAI, Claude, DeepSeek, or OpenRouter/Llama ([free to use](https://openrouter.ai/nousresearch/deephermes-3-llama-3-8b-preview:free/api)). It can be passed as an environment variable (e.g. `OPENAI_API_KEY`/`OPEN_ROUTER_API_KEY`) or added to your `.env` file. The [`.env.sample`](./llm-codegen/.env.sample) file lists all supported environment variables. 15 | 16 | ### How it works? 17 | It orchestrates 3 LLM micro-agents (`Developer`, `Troubleshooter` and `TestsFixer`) to generate code, fix compilation errors, and ensure passing E2E tests. The process includes module code generation, DB migration creation, seeding data, and running tests to validate output. By cycling through these steps, it guarantees consistent and production-ready CRUD code aligned with vertical slicing architecture. It uses `OpenAI/Anthropic/DeepSeek/Llama` LLM API to perform code-generation 18 | 19 | ### How to run? 20 | First, run `npm i` in the project root to install all the template’s dependencies. 21 | Then, navigate to the root `./llm-codegen` folder and run `npm install` to install codegen dependencies. Then execute `npm run start` and provide the requested module description when prompted. 22 | 23 | ```shell 24 | npm i && cd ./llm-codegen && npm i && \ 25 | npm run start -- --name "orders" \ 26 | --description "The module responsible for the orders management. " \ 27 | "It must provide CRUD operations for handling customer orders. " \ 28 | "Users can create new orders, read order details, update order statuses or information, " \ 29 | "and delete orders that are canceled or completed." 30 | ``` 31 | 32 | Finally, after the code generation finishes, review the output, and if the output meets your expectations, begin integrating it into your codebase 33 | 34 | 35 | ![LLMCodegenDemo](./llm-codegen/LLMCodegenDemo.gif) 36 | 37 | The new module’s code is generated from the following Node.js API boilerplate ⬇️ 38 | 39 | --- 40 | 41 | # Node.js API Typescript Template Project 42 | 43 | ## Description 44 | 45 | This project is a simple Node.js boilerplate using TypeScript and Docker. It demonstrates vertical slicing architecture for a REST API, as detailed here: [https://markhneedham.com/blog/2012/02/20/coding-packaging-by-vertical-slice/](https://markhneedham.com/blog/2012/02/20/coding-packaging-by-vertical-slice/). Unlike horizontal slicing (layered architecture), vertical slicing reduces the model code gap, making the modeled domain easier to understand. The implementation also follows the principles of Clean Architecture by Uncle Bob: [https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html). 46 | 47 | The application provides APIs for users to get, create, update, and delete Todo (CRUD operations) 48 | 49 | ## Features 50 | 51 | - Vertical slicing architecture based on **DDD & MVC** principles 52 | - Services input validation using **ZOD** 53 | - Decoupling application components through dependency injection using **InversifyJS** 54 | - Integration and E2E testing with **Supertest** 55 | - **Docker-compose** simplifies multi-service setup, running application, DB, and Redis in isolated docker containers easily 56 | - Simple DB transaction management with **Knex** 57 | - Multi-layer trace ID support for logging with **winston** 58 | - Support graceful shutdown for the **express.js** server 59 | - In-memory data storage and caching with **ioredis** 60 | - Auto-reload on save using **ts-node-dev** 61 | - Automated documentation generation with **TypeDoc** 62 | - Scheduled server-side cron jobs using **node-cron** 63 | - AWS S3 integration for file uploads using **aws-sdk** 64 | 65 | 66 | ## Development 67 | 68 | ### Before install 69 | 70 | Please make sure that you have docker installed [https://docs.docker.com/engine/install/](https://docs.docker.com/engine/install/) 71 | 72 | How to run locally (in dev mode): 73 | 74 | 1. Copy `.env.sample` and rename it to `.env`, providing the appropriate environment variable values. Some of the variables are defined in the docker-compose file 75 | 2. Install dependencies locally `npm i` 76 | 3. Start the app using `npm run docker:run` 77 | 4. By default, the API server is available at `http://localhost:8080/` 78 | 79 | Migrations and seed run automatically 80 | 81 | How to run tests in separate docker containers locally: 82 | 83 | 1. Install dependencies locally `npm i` 84 | 2. Run API tests in separate docker containers `npm run docker:test` 85 | 86 | How to run tests locally with a local SQLite DB: 87 | 88 | 1. Install dependencies locally `npm i` 89 | 2. Execute API tests using a local SQLite DB that stores data in a file: `npm run local:test` 90 | 91 | 92 | ### Application structure 93 | 94 | ```bash 95 | todo-api 96 | ├─ package.json 97 | ├─ src 98 | │ ├─modules (domain components) 99 | │ │ ├─ todos 100 | │ │ │ ├─ tests 101 | │ │ │ ├─ repository 102 | │ │ │ ├─ routes 103 | │ │ │ ├─ controllers 104 | │ │ │ ├─ *.service (business logic implementation) 105 | │ ├─ users 106 | │ ├─ ... 107 | │ │ 108 | ├─ infra (generic cross-component functionality) 109 | │ ├─ data (migrations, seeds) 110 | │ ├─ integrations (services responsible for integrations with 3rd party services - belong to repository layer) 111 | │ ├─ loaders 112 | │ ├─ middlewares 113 | ``` 114 | 115 | 116 | ## API Docs 117 | Comprehensive API documentation is created directly from the source code using **TypeDoc**. To generate the documentation, run: 118 | 119 | 1. Generate documentation: `npm run generate:docs` 120 | 2. Serve documentation locally: `npm run serve:docs` 121 | 122 | After running these commands, the documentation will be accessible at [http://127.0.0.1:8081](http://127.0.0.1:8081). 123 | 124 | Here is Postman collection to work with API locally: 125 | 126 | [Run In Postman](https://app.getpostman.com/run-collection/429508-4b29a48c-b45d-4d09-912f-83090fd70b5e?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D429508-4b29a48c-b45d-4d09-912f-83090fd70b5e%26entityType%3Dcollection%26workspaceId%3Dcb523e15-e316-4367-a52b-6caab455c64a) 127 | 128 | ### API Endpoints 129 | 130 | List of available routes: 131 | 132 | **Auth routes**:\ 133 | `POST /api/signup` - register\ 134 | `POST /api/signin` - login\ 135 | `POST /api/jwt/refresh` - refresh auth token\ 136 | `POST /api/signout` - logout 137 | 138 | **User routes**:\ 139 | `GET /v1/users` - get all users (requires admin access rights)\ 140 | `GET /v1/users/me` - get current user 141 | 142 | **Todo routes**:\ 143 | `POST /api/todos` - create new todo\ 144 | `PUT /api/todos/:todoId` - update todo\ 145 | `GET /api/todos/:todoId` - get specific todo\ 146 | `GET /api/todos/my` - get all users' todos\ 147 | `DELETE /api/todos/:todoId` - delete user\ 148 | `GET /api/todos` - get all created todos (requires admin access rights) 149 | 150 | 151 | ### Modules 152 | 153 | The codebase is organized into modules, with each module representing either a use case (business logic) or an integration service (with a third-party service, e.g., AWS). Each module defines its dependencies using dependency injection and validates input parameters with ZOD. 154 | 155 | ### Dependency injection 156 | 157 | To easily manage dependencies and decouple parts of the application, the **InversifyJS** package is used. Each class or module can consume one of the registered dependencies via decorators. In the dependency container file located at `src/infra/loaders/diContainer.ts`, you can find each dependency and its corresponding imported module. 158 | 159 | ### Logging and tracing 160 | 161 | The application uses the `winston` logger for effective logging and implements cross-layer trace IDs in the winston wrapper output logic. As a result, logs related to the same request but from different layers (service, repository, controller) are outputted with the same trace ID without any extra implementation. 162 | 163 | -------------------------------------------------------------------------------- /llm-codegen/core/agents/orchestrator.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import ora from 'ora'; 5 | 6 | import logger from '../logger'; 7 | import { BaseAgent } from './base'; 8 | import { Developer } from './developer'; 9 | import { TestsFixer } from './testsFixer'; 10 | import { Troubleshooter } from './troubleshooter'; 11 | import { buildTree, printTree } from '../utils'; 12 | 13 | const { 14 | MAX_REGENERATE_CODE_ATTEMPTS = 4, 15 | MAX_FIX_CODE_ATTEMPTS = 4, 16 | MAX_FIX_E2E_TESTS_ATTEMPTS = 4, 17 | } = process.env; 18 | 19 | const GET_BOILERPLATE_CWD = () => path.join(__dirname, '../../..'); 20 | 21 | /** 22 | * @class Orchestrator 23 | * 24 | * The Orchestrator manages the overall code generation pipeline by coordinating 25 | * the execution of micro-agents such as Developer, Troubleshooter, and TestsFixer. 26 | * It oversees the workflow, handles error management, logging, and ensures that each 27 | * micro-agent operates in the correct sequence to generate, troubleshoot, and verify 28 | * the codebase. 29 | */ 30 | export class Orchestrator { 31 | protected moduleName: string; 32 | protected projectDescription: string; 33 | 34 | constructor(projectDescription: string, moduleName: string) { 35 | this.projectDescription = projectDescription; 36 | this.moduleName = moduleName; 37 | } 38 | 39 | async runDeveloper() { 40 | let regenerateAttempts = 0; 41 | const developerAgent = new Developer( 42 | this.projectDescription, 43 | this.moduleName 44 | ); 45 | let validated = await developerAgent.execute(); 46 | 47 | let missingFiles = Object.keys(validated).filter( 48 | (key: string) => validated[key].length === 0 49 | ); 50 | 51 | let lastGeneratedMigration = validated.MIGRATIONS[0]; 52 | let generatedMigration = lastGeneratedMigration; 53 | 54 | let pathToTest = validated.E2E_TESTS[0] || ''; 55 | 56 | let generatedFileKeys = Object.keys(validated).filter( 57 | (key: string) => validated[key].length > 0 58 | ); 59 | 60 | // exclude migrations 61 | let generatedFiles = Object.entries(validated) 62 | .filter(([key]) => key !== 'MIGRATIONS') 63 | .flatMap(([, paths]) => paths); 64 | 65 | while ( 66 | regenerateAttempts < Number(MAX_REGENERATE_CODE_ATTEMPTS) && 67 | missingFiles.length > 0 68 | ) { 69 | logger.info( 70 | `orchestrator:runDeveloper:REGENERATE:regenerateAttempts=${regenerateAttempts}:missingFiles=${ 71 | missingFiles.length 72 | }:${missingFiles.join(', ')}` 73 | ); 74 | 75 | // set missing files 76 | developerAgent.setMissingFiles(missingFiles); 77 | 78 | validated = await developerAgent.execute(); 79 | 80 | if (!pathToTest) { 81 | pathToTest = validated.E2E_TESTS[0]; 82 | } 83 | 84 | missingFiles = Object.keys(validated).filter( 85 | (key: string) => validated[key].length === 0 86 | ); 87 | 88 | missingFiles = missingFiles.filter( 89 | (fileKey: string) => !generatedFileKeys.includes(fileKey) 90 | ); 91 | 92 | generatedFileKeys = generatedFileKeys.concat( 93 | Object.keys(validated).filter( 94 | (key: string) => validated[key].length > 0 95 | ) 96 | ); 97 | 98 | generatedFiles = generatedFiles.concat( 99 | Object.entries(validated) 100 | .filter(([key]) => key !== 'MIGRATIONS') 101 | .flatMap(([, paths]) => paths) 102 | ); 103 | 104 | // remove previously generated migration if we previously generated migration and new again 105 | if (generatedMigration && validated.MIGRATIONS[0]) { 106 | fs.unlinkSync(generatedMigration); 107 | 108 | logger.info( 109 | `orchestrator:runDeveloper:REMOVED:Prev:migration=${generatedMigration}` 110 | ); 111 | generatedMigration = ''; 112 | } 113 | 114 | lastGeneratedMigration = validated.MIGRATIONS[0]; 115 | 116 | if (lastGeneratedMigration) { 117 | generatedMigration = lastGeneratedMigration; 118 | } 119 | 120 | if (missingFiles.length > 0) { 121 | logger.warn( 122 | `orchestrator:runDeveloper:some files are missing after ${regenerateAttempts} attempt:missingFiles=${missingFiles.join( 123 | ', ' 124 | )}` 125 | ); 126 | } 127 | 128 | regenerateAttempts += 1; 129 | } 130 | 131 | return { 132 | regenerateAttempts, 133 | result: validated, 134 | developerAgent, 135 | generatedFiles, 136 | generatedMigration, 137 | missingFiles, 138 | pathToTest, 139 | }; 140 | } 141 | 142 | async runTroubleshooter( 143 | missingFiles: string[], 144 | generatedServices: string[], 145 | generatedMigration: string 146 | ) { 147 | let fixAttempts = 0; 148 | let lastGeneratedMigration; 149 | 150 | let validatedOutput = 151 | missingFiles.length > 0 152 | ? `Next files were not generated: ${missingFiles.join(',')}` 153 | : ''; 154 | 155 | // run the TypeScript compiler 156 | let output = await this.runTSCompiler(); 157 | 158 | const troubleshooterAgent = new Troubleshooter( 159 | this.projectDescription, 160 | this.moduleName, 161 | generatedServices 162 | ); 163 | 164 | let validated: Record = {}; 165 | let generatedFiles: string[] = []; 166 | 167 | while ( 168 | fixAttempts < Number(MAX_FIX_CODE_ATTEMPTS) && 169 | (!output.success || validatedOutput) 170 | ) { 171 | if (fixAttempts > 0) { 172 | logger.info( 173 | `orchestrator:runTroubleshooter:fixAttempts=${fixAttempts}` 174 | ); 175 | } 176 | 177 | troubleshooterAgent.setErrorText(output.output || validatedOutput); 178 | validated = await troubleshooterAgent.execute(); 179 | 180 | fixAttempts += 1; 181 | 182 | const fixedMissingFiles = Object.keys(validated) 183 | .filter((key: string) => validated[key].length === 0) 184 | // check if file still missing from originally generated code 185 | .filter((mf) => missingFiles.indexOf(mf) > -1); 186 | 187 | // remove previously generated migration if we previously generated migration and new again 188 | if (generatedMigration && validated.MIGRATIONS[0]) { 189 | fs.unlinkSync(generatedMigration); 190 | 191 | logger.info( 192 | `orchestrator:runTroubleshooter:REMOVED:Prev:migration=${generatedMigration}` 193 | ); 194 | generatedMigration = ''; 195 | } 196 | 197 | lastGeneratedMigration = validated.MIGRATIONS[0]; 198 | 199 | if (lastGeneratedMigration) { 200 | generatedMigration = lastGeneratedMigration; 201 | } 202 | 203 | generatedFiles = generatedFiles.concat( 204 | Object.entries(validated) 205 | .filter(([key]) => key !== 'MIGRATIONS') 206 | .flatMap(([, paths]) => paths) 207 | ); 208 | 209 | validatedOutput = 210 | fixedMissingFiles.length > 0 211 | ? `Next files were not generated: ${fixedMissingFiles.join(',')}` 212 | : ''; 213 | // Run the compiler again 214 | output = await this.runTSCompiler(); 215 | 216 | if (!output.success || validatedOutput) { 217 | logger.error( 218 | `orchestrator:runTroubleshooter:TypeScript compilation failed:validatedOutput=${validatedOutput}:output.success=${output.success}` 219 | ); 220 | // Optionally, you can handle this case further 221 | } else { 222 | logger.info('runTroubleshooter:TypeScript compilation succeeded'); 223 | } 224 | } 225 | 226 | return { 227 | troubleshooterAgent, 228 | result: validated, 229 | generatedFiles, 230 | generatedMigration: lastGeneratedMigration, 231 | fixAttempts, 232 | output, 233 | }; 234 | } 235 | 236 | async runTestsFixer( 237 | pathToTest: string, 238 | generatedServices: string[], 239 | generatedMigration: string 240 | ) { 241 | // Run tests using npm 242 | let testOutput = await this.runTests(pathToTest); 243 | let testAttempts = 0; 244 | 245 | const testsFixerAgent = new TestsFixer( 246 | this.projectDescription, 247 | this.moduleName, 248 | generatedServices, 249 | generatedMigration 250 | ); 251 | 252 | let lastGeneratedMigration = generatedMigration; 253 | let validated: Record = {}; 254 | 255 | while ( 256 | testAttempts < Number(MAX_FIX_E2E_TESTS_ATTEMPTS) && 257 | !testOutput.success 258 | ) { 259 | if (testAttempts > 0) { 260 | logger.info(`orchestrator:runTestsFixer:fixAttempts=${testAttempts}`); 261 | } 262 | 263 | // update path to recent migration 264 | if (lastGeneratedMigration) { 265 | testsFixerAgent.setMigrationPath(lastGeneratedMigration); 266 | } 267 | testsFixerAgent.setErrorText(testOutput.output); 268 | 269 | // try to fix E2E tests 270 | validated = await testsFixerAgent.execute(); 271 | 272 | testAttempts += 1; 273 | 274 | // remove previously generated migration if we previously generated migration and new again 275 | if (generatedMigration && validated.MIGRATIONS?.[0]) { 276 | fs.unlinkSync(generatedMigration); 277 | logger.info( 278 | `orchestrator:runTestsFixer:REMOVED:Prev:migration=${generatedMigration}` 279 | ); 280 | generatedMigration = ''; 281 | } 282 | 283 | lastGeneratedMigration = validated.MIGRATIONS?.[0]; 284 | 285 | if (lastGeneratedMigration) { 286 | generatedMigration = lastGeneratedMigration; 287 | } 288 | 289 | testOutput = await this.runTests(pathToTest); 290 | 291 | if (!testOutput.success) { 292 | logger.warn(`orchestrator:runTestsFixer:E2E tests failed`); 293 | } else { 294 | logger.info(`orchestrator:runTestsFixer:E2E tests passed successfully`); 295 | } 296 | } 297 | 298 | return { 299 | testsFixerAgent, 300 | testAttempts, 301 | testOutput, 302 | generatedMigration: lastGeneratedMigration, 303 | }; 304 | } 305 | 306 | async execute(projectDescription: string, moduleName: string) { 307 | logger.info('orchestrator:start:code:generation'); 308 | 309 | this.projectDescription = projectDescription; 310 | this.moduleName = moduleName; 311 | 312 | // 1st step - generate all necessary code 313 | const result = await this.runDeveloper(); 314 | 315 | let validated = result.result; 316 | let generatedMigration = result.generatedMigration; 317 | const { 318 | developerAgent, 319 | generatedFiles, 320 | missingFiles, 321 | pathToTest, 322 | regenerateAttempts, 323 | } = result; 324 | 325 | const generatedServices = validated.SERVICES?.map((generateServicePath) => 326 | path.basename(generateServicePath) 327 | ); 328 | 329 | // 2nd step - compile all code and fix errors if needed 330 | const troubleshooterResult = await this.runTroubleshooter( 331 | missingFiles, 332 | generatedServices, 333 | generatedMigration 334 | ); 335 | 336 | generatedMigration = 337 | troubleshooterResult.generatedMigration ?? generatedMigration; 338 | 339 | const { troubleshooterAgent, fixAttempts, output } = troubleshooterResult; 340 | 341 | generatedFiles.push(...troubleshooterResult.generatedFiles); 342 | 343 | // exit if the code was not compiled successfully 344 | if (fixAttempts === Number(MAX_FIX_CODE_ATTEMPTS) && !output.success) { 345 | const totalStats = this.getAllStats([ 346 | developerAgent, 347 | troubleshooterAgent, 348 | ]); 349 | 350 | logger.info( 351 | `orchestrator:TypeScript compilation failed again after fixing the code - skipping E2E tests: regenerateAttempts=${regenerateAttempts}:fixAttempts=${fixAttempts}` 352 | ); 353 | logger.info( 354 | `orchestrator:LLM token usage stats:inputTokens=${totalStats.totalInputTokens}:outputTokens=${totalStats.totalOutputTokens}` 355 | ); 356 | 357 | return; 358 | } 359 | 360 | logger.info(`orchestrator:E2E tests ${pathToTest}`); 361 | 362 | // 3rd step - run e2e tests and fix them 363 | const testFixerResult = await this.runTestsFixer( 364 | pathToTest, 365 | generatedServices, 366 | generatedMigration 367 | ); 368 | const { testsFixerAgent, testAttempts, testOutput } = testFixerResult; 369 | 370 | generatedMigration = 371 | testFixerResult.generatedMigration ?? generatedMigration; 372 | 373 | const totalStats = this.getAllStats([ 374 | developerAgent, 375 | troubleshooterAgent, 376 | testsFixerAgent, 377 | ]); 378 | 379 | logger.info( 380 | `orchestrator:TypeScript compilation and E2E tests completed ${ 381 | testOutput.success ? 'successfully' : 'with errors' 382 | }:regenerateAttempts=${regenerateAttempts}:fixAttempts=${fixAttempts}:testAttempts=${testAttempts}` 383 | ); 384 | 385 | logger.info( 386 | `orchestrator:LLM token usage stats:inputTokens=${totalStats.totalInputTokens}:outputTokens=${totalStats.totalOutputTokens}` 387 | ); 388 | 389 | const rootDir = path.resolve(__dirname, '../../..'); 390 | 391 | // add recent migration path 392 | generatedFiles.push(generatedMigration); 393 | 394 | // convert absolute paths to relative paths 395 | const relativePaths = generatedFiles.map((p) => path.relative(rootDir, p)); 396 | 397 | // build a nested object from the relative paths 398 | const tree = buildTree(relativePaths); 399 | 400 | logger.info('Generated files:'); 401 | // print the project name as root, then the tree 402 | logger.info('todo-api-boilerplate (root folder)'); 403 | // recursively print the tree 404 | printTree(tree); 405 | } 406 | 407 | getAllStats(agents: BaseAgent[]) { 408 | let totalInputTokens = 0; 409 | let totalOutputTokens = 0; 410 | 411 | for (const agent of agents) { 412 | const stats = agent.getUsageStats(); 413 | 414 | totalInputTokens += stats.inputTokens; 415 | totalOutputTokens += stats.outputTokens; 416 | } 417 | 418 | return { totalInputTokens, totalOutputTokens }; 419 | } 420 | 421 | async runTSCompiler() { 422 | return new Promise<{ success: boolean; output: string }>((resolve) => { 423 | const spinner = ora(`compiling TypeScript code...`).start(); 424 | exec( 425 | 'tsc -p .', 426 | { cwd: GET_BOILERPLATE_CWD() }, 427 | (error, stdout, stderr) => { 428 | if (error) { 429 | spinner.fail(`TypeScript compilation failed`); 430 | 431 | logger.error( 432 | `\r\nTypeScript compiler error:\n${ 433 | stderr || stdout 434 | }:error=${error}` 435 | ); 436 | resolve({ 437 | success: false, 438 | output: stdout || stderr, 439 | }); 440 | } else { 441 | spinner.succeed('TypeScript compilation completed successfully'); 442 | 443 | resolve({ 444 | success: true, 445 | output: stdout, 446 | }); 447 | } 448 | } 449 | ); 450 | }); 451 | } 452 | 453 | async runTests(testToRun: string) { 454 | return new Promise<{ success: boolean; output: string }>((resolve) => { 455 | const spinner = ora(`running generated E2E tests...`).start(); 456 | const command = `npm run local:test ${testToRun}`; 457 | 458 | exec(command, { cwd: GET_BOILERPLATE_CWD() }, (error, stdout, stderr) => { 459 | if (error) { 460 | spinner.fail('Generated E2E tests completed with errors'); 461 | logger.error( 462 | `runTests:tests:error:STDOUT=${stdout}:STDERR=${stderr}:error=${error}` 463 | ); 464 | resolve({ 465 | success: false, 466 | output: `${stdout || stderr}\r\n${error}`, 467 | }); 468 | } else { 469 | spinner.succeed(`Generated E2E tests completed successfully`); 470 | resolve({ 471 | success: true, 472 | output: stdout, 473 | }); 474 | } 475 | }); 476 | }); 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /llm-codegen/core/agents/base.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import ora from 'ora'; 3 | import * as path from 'path'; 4 | 5 | import { AnthropicLLMClient } from '../llmClients/anthropicLLMClient'; 6 | import { BaseLLMClient } from '../llmClients/baseLLMClient'; 7 | import { OpenAILLMClient } from '../llmClients/openAILLMClient'; 8 | import { OpenRouterLLMClient } from '../llmClients/openRouterLLMClient'; 9 | import { DeepSeekLLMClient } from '../llmClients/deepSeekLLMClient'; 10 | import logger from '../logger'; 11 | import { 12 | capitalizeFirstLetter, 13 | extractFileName, 14 | lowerCaseFirstLetter, 15 | } from '../utils'; 16 | import { EXAMPLE_MODULE_FILE_PATHS } from '../constants'; 17 | 18 | /** 19 | * @class BaseAgent 20 | * 21 | * The BaseAgent class serves as an abstract foundation for all micro-agents within the code generation pipeline. 22 | * It manages common functionalities such as loading source files, preparing prompts, executing LLM requests, 23 | * and saving generated code. The class also handles the initialization of the appropriate LLM client 24 | * based on available API keys. 25 | */ 26 | export class BaseAgent { 27 | protected promptTemplateName: string; 28 | protected moduleName: string; 29 | protected projectDescription: string; 30 | protected templateMappings: Record = {}; 31 | protected inputTokens: number; 32 | protected outputTokens: number; 33 | protected llmClient: BaseLLMClient; 34 | 35 | constructor( 36 | promptTemplateName: string, 37 | projectDescription: string, 38 | moduleName: string 39 | ) { 40 | this.moduleName = moduleName; 41 | this.projectDescription = projectDescription; 42 | 43 | this.inputTokens = 0; 44 | this.outputTokens = 0; 45 | 46 | this.promptTemplateName = promptTemplateName; 47 | 48 | // create appropriate LLM client 49 | if (process.env.OPENAI_API_KEY) { 50 | this.llmClient = new OpenAILLMClient(); 51 | logger.info( 52 | `${this.constructor.name.toLowerCase()}:using OpenAI LLM` 53 | ); 54 | } else if (process.env.ANTHROPIC_API_KEY) { 55 | this.llmClient = new AnthropicLLMClient(); 56 | logger.info( 57 | `${this.constructor.name.toLowerCase()}:using Anthropic LLM` 58 | ); 59 | } else if (process.env.OPEN_ROUTER_API_KEY) { 60 | this.llmClient = new OpenRouterLLMClient(); 61 | logger.info( 62 | `${this.constructor.name.toLowerCase()}:using OpenRouter LLM` 63 | ); 64 | } else if (process.env.DEEP_SEEK_API_KEY) { 65 | this.llmClient = new DeepSeekLLMClient(); 66 | logger.info( 67 | `${this.constructor.name.toLowerCase()}:using DeepSeek LLM` 68 | ); 69 | } else { 70 | throw new Error( 71 | 'Provide API key for at least one LLM client - OpenAI, Anthropic, DeepSeek or OpenRouter' 72 | ); 73 | } 74 | } 75 | 76 | async loadSourceFile(filePath: string, templateProp = ''): Promise { 77 | const absolutePath = !filePath.startsWith('/') 78 | ? path.join(__dirname, '../../..', filePath) 79 | : filePath; 80 | try { 81 | const content = await fs.readFile(absolutePath, 'utf-8'); 82 | 83 | return content; 84 | } catch (error) { 85 | logger.error( 86 | `loadSourceFile:error:reading:templateProp=${templateProp}:file:${absolutePath}:`, 87 | error 88 | ); 89 | return ''; 90 | } 91 | } 92 | 93 | async loadSourceFileMap( 94 | sourceFileMap: Record 95 | ): Promise> { 96 | const entries = Object.entries(sourceFileMap); 97 | 98 | const results = await Promise.all( 99 | entries.map(async ([templateProp, filePath]) => { 100 | const fileContent = await this.loadSourceFile(filePath, templateProp); 101 | return [templateProp, fileContent] as const; 102 | }) 103 | ); 104 | 105 | return Object.fromEntries(results); 106 | } 107 | 108 | async preparePrompt() { 109 | // load context of the prompt 110 | let replacedPromptTemplate = await fs.readFile( 111 | path.join(__dirname, '..', 'prompts', this.promptTemplateName), 112 | 'utf-8' 113 | ); 114 | 115 | for (const templateEntry in this.templateMappings) { 116 | replacedPromptTemplate = replacedPromptTemplate.replace( 117 | templateEntry, 118 | this.templateMappings[templateEntry] 119 | ); 120 | } 121 | 122 | logger.info( 123 | `${this.constructor.name.toLowerCase()}:preparePrompt:prompt=${ 124 | replacedPromptTemplate.length 125 | }:characters` 126 | ); 127 | return replacedPromptTemplate; 128 | } 129 | 130 | async execute() { 131 | let spinner; 132 | try { 133 | const start = Date.now(); 134 | const preparedPrompt = await this.preparePrompt(); 135 | 136 | // animate request 137 | spinner = ora( 138 | `${this.constructor.name.toLowerCase()}:making LLM request` 139 | ).start(); 140 | 141 | const { 142 | content: generatedCode, 143 | inputTokens, 144 | outputTokens, 145 | } = await this.llmClient.execute(preparedPrompt); 146 | 147 | spinner.succeed( 148 | `${this.constructor.name.toLowerCase()}:LLM request completed in ${Number( 149 | (Date.now() - start) / 1000 150 | ).toFixed(1)} seconds` 151 | ); 152 | 153 | // update stats 154 | this.inputTokens += inputTokens || 0; 155 | this.outputTokens += outputTokens || 0; 156 | 157 | return this.saveGeneratedCode(generatedCode); 158 | } catch (ex) { 159 | spinner?.fail( 160 | `${this.constructor.name.toLowerCase()}:LLM request failed:${ 161 | (ex as Error).name 162 | }` 163 | ); 164 | logger.error( 165 | `\r\n${this.constructor.name.toLowerCase()}:execute:error=${ 166 | (ex as Error).name 167 | }:${(ex as Error).message}`, 168 | ex 169 | ); 170 | } 171 | return {}; 172 | } 173 | 174 | /** 175 | * Parses a raw header line to extract the section name and class name. 176 | * Expected format: " - " or just "". 177 | */ 178 | private parseHeader(rawHeader: string): { 179 | sectionName: string; 180 | className?: string; 181 | } { 182 | const parsedHeader = rawHeader.trim().split(/\s+-\s+/); 183 | // extract the section name (removing numbering if present) 184 | const headerMatch = rawHeader.match(/(?:\d+\.\s*)?(\w+)/i); 185 | 186 | if (!headerMatch) { 187 | throw new Error(`Could not parse section header: ${rawHeader}`); 188 | } 189 | 190 | const sectionName = headerMatch[1].toUpperCase(); 191 | const className = parsedHeader[1] 192 | ? extractFileName(parsedHeader[1]) 193 | : undefined; 194 | 195 | return { sectionName, className }; 196 | } 197 | 198 | /** 199 | * Determines the file path(s) for a given section. 200 | * Returns an array of file paths because some sections can produce multiple files. 201 | */ 202 | private async determineFilePaths( 203 | sectionName: string, 204 | className?: string 205 | ): Promise> { 206 | switch (sectionName) { 207 | case 'ROUTES': 208 | return { 209 | ROUTES: [ 210 | path.join( 211 | __dirname, 212 | '../../..', 213 | 'src', 214 | 'modules', 215 | this.moduleName, 216 | 'routes.ts' 217 | ), 218 | ], 219 | }; 220 | case 'CONTROLLERS': 221 | return { 222 | CONTROLLERS: [ 223 | path.join( 224 | __dirname, 225 | '../../..', 226 | 'src', 227 | 'modules', 228 | this.moduleName, 229 | 'controllers.ts' 230 | ), 231 | ], 232 | }; 233 | case 'REPOSITORY': 234 | return { 235 | REPOSITORY: [ 236 | path.join( 237 | __dirname, 238 | '../../..', 239 | 'src', 240 | 'modules', 241 | this.moduleName, 242 | 'repository.ts' 243 | ), 244 | ], 245 | }; 246 | case 'SERVICE': { 247 | const fileName = className 248 | ? `${lowerCaseFirstLetter(className)}.service.ts` 249 | : `get${capitalizeFirstLetter(this.moduleName)}.service.ts`; 250 | 251 | return { 252 | SERVICES: [ 253 | path.join( 254 | __dirname, 255 | '../../..', 256 | 'src', 257 | 'modules', 258 | this.moduleName, 259 | fileName 260 | ), 261 | ], 262 | }; 263 | } 264 | case 'DI_CONFIG': 265 | case 'DEPENDENCY_INJECTION': 266 | return { 267 | DI_CONFIG: [ 268 | path.join( 269 | __dirname, 270 | '../../..', 271 | 'src', 272 | 'modules', 273 | this.moduleName, 274 | 'diConfig.ts' 275 | ), 276 | ], 277 | }; 278 | case 'TYPES': 279 | return { 280 | TYPES: [ 281 | path.join( 282 | __dirname, 283 | '../../..', 284 | 'src', 285 | 'modules', 286 | this.moduleName, 287 | 'types.ts' 288 | ), 289 | ], 290 | }; 291 | case 'E2E_TESTS': 292 | case 'TESTS': 293 | return { 294 | E2E_TESTS: [ 295 | path.join( 296 | __dirname, 297 | '../../..', 298 | 'src', 299 | 'modules', 300 | this.moduleName, 301 | 'tests', 302 | 'api.spec.ts' 303 | ), 304 | ], 305 | }; 306 | case 'MIGRATIONS': { 307 | const timestamp = new Date() 308 | .toISOString() 309 | .replace(/[-T:.Z]/g, '') 310 | .slice(0, -3); 311 | return { 312 | MIGRATIONS: [ 313 | path.join( 314 | __dirname, 315 | '../../..', 316 | 'src', 317 | 'infra', 318 | 'data', 319 | 'migrations', 320 | `${timestamp}_create_${this.moduleName}_table.ts` 321 | ), 322 | ], 323 | }; 324 | } 325 | case 'ALL_API_ROUTES': 326 | return { 327 | ALL_API_ROUTES: [ 328 | path.join( 329 | __dirname, 330 | '../../..', 331 | EXAMPLE_MODULE_FILE_PATHS.ALL_API_ROUTES 332 | ), 333 | ], 334 | }; 335 | case 'ALL_CONSTANTS': 336 | return { 337 | ALL_CONSTANTS: [ 338 | path.join( 339 | __dirname, 340 | '../../..', 341 | EXAMPLE_MODULE_FILE_PATHS.ALL_CONSTANTS 342 | ), 343 | ], 344 | }; 345 | case 'ALL_DI_CONFIG': 346 | return { 347 | ALL_DI_CONFIG: [ 348 | path.join( 349 | __dirname, 350 | '../../..', 351 | EXAMPLE_MODULE_FILE_PATHS.ALL_DI_CONFIG 352 | ), 353 | ], 354 | }; 355 | case 'ALL_SEEDS': 356 | return { 357 | ALL_SEEDS: [ 358 | path.join( 359 | __dirname, 360 | '../../..', 361 | EXAMPLE_MODULE_FILE_PATHS.ALL_SEEDS 362 | ), 363 | ], 364 | }; 365 | default: 366 | throw new Error(`Unknown section name: ${sectionName}`); 367 | } 368 | } 369 | 370 | /** 371 | * Cleans and returns the content lines for a given section. 372 | * Filters out lines starting with '`' and trims the result. 373 | */ 374 | private getSectionContent(contentArray: string[]): string { 375 | return contentArray 376 | .filter((line) => !line.startsWith('`')) 377 | .join('\n') 378 | .trim(); 379 | } 380 | 381 | public async saveGeneratedCode(generatedCode: string) { 382 | logger.info( 383 | `${this.constructor.name.toLowerCase()}:saveGeneratedCode:moduleName=${ 384 | this.moduleName 385 | }:generatedCode=${generatedCode.length}:characters` 386 | ); 387 | 388 | // split the generated code using regex to match both '***' and '###' delimiters 389 | const sections = generatedCode.split(/(?:\*{3}|#{3})\s*/); 390 | 391 | const allNecessarySectionsToUpdate: { [key: string]: string[] } = { 392 | ALL_API_ROUTES: [], 393 | ALL_CONSTANTS: [], 394 | ALL_DI_CONFIG: [], 395 | ALL_SEEDS: [], 396 | CONTROLLERS: [], 397 | REPOSITORY: [], 398 | DI_CONFIG: [], 399 | ROUTES: [], 400 | MIGRATIONS: [], 401 | SERVICES: [], 402 | E2E_TESTS: [], 403 | }; 404 | 405 | const allGeneratedHeaders: string[] = []; 406 | 407 | for (const section of sections) { 408 | const trimmedSection = section.trim(); 409 | if (!trimmedSection) { 410 | // skip empty sections 411 | continue; 412 | } 413 | 414 | const [rawHeader, ...contentArray] = trimmedSection.split('\n'); 415 | if (!rawHeader) { 416 | // logger.error(`${this.constructor.name.toLowerCase()}:saveGeneratedCode:EMPTY_HEADER`); 417 | continue; 418 | } 419 | 420 | allGeneratedHeaders.push(rawHeader); 421 | 422 | if (!contentArray.length) { 423 | // logger.error( 424 | // `${this.constructor.name.toLowerCase()}:saveGeneratedCode:EMPTY:content for header: ${rawHeader}` 425 | // ); 426 | continue; 427 | } 428 | 429 | const content = this.getSectionContent(contentArray); 430 | if (!content) { 431 | logger.error( 432 | `${this.constructor.name}:saveGeneratedCode:EMPTY_CONTENT:${rawHeader}` 433 | ); 434 | continue; 435 | } 436 | 437 | let sectionName: string; 438 | let className: string | undefined; 439 | 440 | try { 441 | const parsed = this.parseHeader(rawHeader); 442 | 443 | sectionName = parsed.sectionName; 444 | className = parsed.className; 445 | } catch (err: any) { 446 | logger.error( 447 | `${this.constructor.name.toLowerCase()}:saveGeneratedCode:headerParseError: ${ 448 | err.message 449 | }` 450 | ); 451 | continue; 452 | } 453 | 454 | let filePaths: string[]; 455 | let sectionFileName; 456 | 457 | try { 458 | const sectionFilePaths = await this.determineFilePaths( 459 | sectionName, 460 | className 461 | ); 462 | [[sectionFileName, filePaths]] = Object.entries(sectionFilePaths); 463 | } catch (err) { 464 | logger.error( 465 | `${this.constructor.name.toLowerCase()}:saveGeneratedCode:error:unknown section: ${sectionName}` 466 | ); 467 | continue; 468 | } 469 | 470 | // Write the content to the determined file paths 471 | for (const filePath of filePaths) { 472 | await fs.mkdir(path.dirname(filePath), { recursive: true }); 473 | await fs.writeFile(filePath, content); 474 | 475 | // logger.info( 476 | // `${this.constructor.name}:saveGeneratedCode:WROTE_FILE:sectionFileName=${sectionFileName}:file=${filePath}` 477 | // ); 478 | } 479 | 480 | // Update the tracking object 481 | // The keys in allNecessarySectionsToUpdate are uppercase or a specific name 482 | if (allNecessarySectionsToUpdate[sectionFileName]) { 483 | allNecessarySectionsToUpdate[sectionFileName].push(...filePaths); 484 | } 485 | } 486 | 487 | // logger.info( 488 | // `${ 489 | // this.constructor.name 490 | // }:saveGeneratedCode:COMPLETED:allGeneratedHeaders=${allGeneratedHeaders.join( 491 | // ', ' 492 | // )}` 493 | // ); 494 | logger.info( 495 | `${this.constructor.name.toLowerCase()}:saveGeneratedCode:saved=${ 496 | Object.values(allNecessarySectionsToUpdate).flat().length 497 | }:files` 498 | ); 499 | return allNecessarySectionsToUpdate; 500 | } 501 | 502 | getUsageStats() { 503 | return { 504 | inputTokens: this.inputTokens, 505 | outputTokens: this.outputTokens, 506 | }; 507 | } 508 | } 509 | --------------------------------------------------------------------------------