nestjs-resource-gen
Version:
A CLI tool to generate NestJS resources (controllers, services, DTOs, modules) from entity files
1,272 lines (1,237 loc) âĸ 58.1 kB
JavaScript
#!/usr/bin/env node
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
function parseEntityFile(filePath) {
const content = fs.readFileSync(filePath, "utf-8");
const lines = content.split("\n");
// Extract class name - look for "export class" followed by the class name
const classMatch = content.match(/export\s+class\s+(\w+)/);
if (!classMatch) {
throw new Error("Could not find entity class name");
}
const className = classMatch[1];
// Extract table name from @Entity decorator
const entityMatch = content.match(/@Entity\(['"`]([^'"`]+)['"`]\)/);
const tableName = entityMatch ? entityMatch[1] : className.toLowerCase();
// Extract fields - look for property definitions with decorators
const fields = [];
// Split content into lines and process each line
const contentLines = content.split("\n");
for (let i = 0; i < contentLines.length; i++) {
const line = contentLines[i].trim();
// Look for property definitions (field name followed by colon and type)
const propertyMatch = line.match(/^(\w+)(\?)?\s*:\s*([^;]+);/);
if (!propertyMatch)
continue;
const fieldName = propertyMatch[1];
const isOptional = !!propertyMatch[2];
let fieldType = propertyMatch[3].trim();
// Skip if this looks like a decorator or import
if (fieldName.startsWith("@") ||
fieldName === "import" ||
fieldName === "export")
continue;
// Extract decorators for this field by looking backwards
const decorators = [];
for (let j = i - 1; j >= 0; j--) {
const prevLine = contentLines[j].trim();
if (prevLine.startsWith("@")) {
decorators.unshift(prevLine);
}
else if (prevLine &&
!prevLine.startsWith("//") &&
!prevLine.startsWith("import") &&
!prevLine.startsWith("export")) {
break;
}
}
// Clean up field type - remove any decorator properties
const decoratorProperties = [
"cascade",
"eager",
"nullable",
"onDelete",
"onUpdate",
"primary",
"unique",
"default",
"length",
"precision",
"scale",
"enum",
"array",
"transformer",
"comment",
];
// Remove any decorator properties from the field type
decoratorProperties.forEach((prop) => {
const propRegex = new RegExp(`\\s*,\\s*${prop}\\s*:\\s*[^,}]+`, "g");
fieldType = fieldType.replace(propRegex, "");
});
// Clean up any remaining decorator syntax
fieldType = fieldType
.replace(/\s*,\s*}\s*$/, "}") // Remove trailing comma before closing brace
.replace(/\s*{\s*}\s*$/, "") // Remove empty object braces
.replace(/\s*,\s*$/, "") // Remove trailing comma
.trim();
fields.push({
name: fieldName,
type: fieldType,
isOptional,
decorators,
});
}
return {
name: className.toLowerCase(),
className,
tableName,
fields,
};
}
function generateDTO(entityInfo) {
const { className, fields } = entityInfo;
// Collect relationship DTOs for imports
const relationshipDTOs = [];
const exposedFields = fields
.filter((field) => !field.decorators.some((d) => d.includes("@Exclude")))
.map((field) => {
const decorators = [];
// Check if this is a relationship field
const isRelationship = field.decorators.some((d) => d.includes("@OneToOne") ||
d.includes("@OneToMany") ||
d.includes("@ManyToOne") ||
d.includes("@ManyToMany"));
if (isRelationship) {
// Extract the related entity name from the relationship decorator
let relatedEntityName = "";
for (const decorator of field.decorators) {
const match = decorator.match(/@(?:OneToOne|OneToMany|ManyToOne|ManyToMany)\s*\(\s*\(\)\s*=>\s*(\w+)/);
if (match) {
relatedEntityName = match[1];
break;
}
}
if (relatedEntityName) {
// Add @Type decorator for the relationship
decorators.push(`@Type(() => ${relatedEntityName}DTO)`);
relationshipDTOs.push(relatedEntityName);
}
}
// Add @Expose decorator
decorators.push("@Expose()");
// Add type-specific validators (only for non-relationship fields)
if (!isRelationship) {
if (field.type.includes("string")) {
decorators.push("@IsString()");
if (!field.isOptional) {
decorators.push("@IsNotEmpty()");
}
}
else if (field.type.includes("number")) {
decorators.push("@IsNumber()");
if (!field.isOptional) {
decorators.push("@IsNotEmpty()");
}
}
else if (field.type.includes("boolean")) {
decorators.push("@IsBoolean()");
}
else if (field.type.includes("Date")) {
// Check if it's a date or timestamp column
const hasDateColumn = field.decorators.some((d) => d.includes("@Column") && d.includes("type: 'date'"));
const hasTimestampColumn = field.decorators.some((d) => d.includes("@Column") && d.includes("type: 'timestamp'"));
if (hasTimestampColumn) {
decorators.push("@IsDateString()");
}
else {
decorators.push("@IsDate()");
}
}
if (field.isOptional) {
decorators.push("@IsOptional()");
}
}
// For relationships, use the DTO type instead of the entity type
let fieldType = field.type;
if (isRelationship && relationshipDTOs.length > 0) {
const relatedEntityName = relationshipDTOs[relationshipDTOs.length - 1];
fieldType = `${relatedEntityName}DTO`;
}
return ` ${decorators.join("\n ")}
${field.name}${field.isOptional ? "?" : ""}: ${fieldType};`;
})
.join("\n\n");
// Generate imports for relationship DTOs
const relationshipImports = relationshipDTOs.length > 0
? `import { ${relationshipDTOs.map((name) => `${name}DTO`).join(", ")} } from '../../../shared/DTO';`
: "";
return `import { Expose, Type } from 'class-transformer';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsDate,
IsDateString,
} from 'class-validator';
import { OmitType, PartialType } from '@nestjs/mapped-types';
${relationshipImports}
export class ${className}DTO {
${exposedFields}
}
export class ${className}CreateDTO extends OmitType(${className}DTO, [
'id',
]) {}
export class ${className}PatchDTO extends PartialType(${className}DTO) {}
`;
}
function generateController(entityInfo, entityFilePath) {
const { className, name } = entityInfo;
// Calculate relative paths based on nesting level
const nestingLevel = getEntityNestingLevel(entityFilePath);
const basePath = "../".repeat(nestingLevel + 1);
const authPath = `${basePath}auth/guard`;
const authzPath = `${basePath}authz`;
const interceptorPath = `${basePath}interceptor/serialize.interceptor`;
const sharedPath = `${basePath}shared/Id.params`;
const entitiesPath = `${basePath}database/entities`;
return `import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '${authPath}';
import { PermissionsGuard } from '${authzPath}/guard';
import { ${className}Service } from './${name}.service';
import { Serialize } from '${interceptorPath}';
import { ${className}CreateDTO, ${className}DTO, ${className}PatchDTO } from './dtos';
import { CheckPermissions } from '${authzPath}/decorator';
import { PermissionAction } from '${authzPath}/casl-ability.factory';
import { IdParams } from '${sharedPath}';
import { PERMISSION_ITEM_NAME } from '${entitiesPath}';
@Controller()
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class ${className}Controller {
constructor(private _${name}Service: ${className}Service) {}
@Get('/')
@Serialize(${className}DTO)
@CheckPermissions([PermissionAction.READ, PERMISSION_ITEM_NAME.${className.toUpperCase()}S])
async getAll${className}s() {
return await this._${name}Service.getAll${className}s();
}
@Get('/:id')
@Serialize(${className}DTO)
@CheckPermissions([PermissionAction.READ, PERMISSION_ITEM_NAME.${className.toUpperCase()}S])
async get${className}(@Param() { id }: IdParams) {
return await this._${name}Service.get${className}ById(id);
}
@Delete('/:id')
@CheckPermissions([PermissionAction.DELETE, PERMISSION_ITEM_NAME.${className.toUpperCase()}S])
async delete${className}(@Param() { id }: IdParams) {
return await this._${name}Service.delete${className}(id);
}
@Post('/')
@Serialize(${className}DTO)
@CheckPermissions([PermissionAction.CREATE, PERMISSION_ITEM_NAME.${className.toUpperCase()}S])
async create${className}(@Body() ${name}: ${className}CreateDTO) {
return await this._${name}Service.create${className}(${name});
}
@Patch('/:id')
@Serialize(${className}PatchDTO)
@CheckPermissions([PermissionAction.UPDATE, PERMISSION_ITEM_NAME.${className.toUpperCase()}S])
async patch${className}(
@Param() { id }: IdParams,
@Body() ${name}: ${className}PatchDTO,
) {
return await this._${name}Service.patch${className}(id, ${name});
}
}`;
}
function generateService(entityInfo, entityFilePath) {
const { className, name } = entityInfo;
// Calculate relative paths based on nesting level
const nestingLevel = getEntityNestingLevel(entityFilePath);
const basePath = "../".repeat(nestingLevel + 1);
const entitiesPath = `${basePath}database/entities`;
const sharedPath = `${basePath}shared/CustomQuery.exceptionfilter`;
const authzPath = `${basePath}authz`;
return `import {
BadRequestException,
Injectable,
NotFoundException,
Scope,
} from '@nestjs/common';
import { FindOptionsRelations, Not, Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { ${className}, PERMISSION_ITEM_NAME } from '${entitiesPath}';
import { CustomQueryException } from '${sharedPath}';
import { ${className}CreateDTO, ${className}PatchDTO } from './dtos/${name}.dto';
import { AuthzService } from '${authzPath}/authz.service';
import { PermissionAction } from '${authzPath}/casl-ability.factory';
@Injectable({ scope: Scope.REQUEST })
export class ${className}Service {
constructor(
@InjectRepository(${className})
private ${name}Repository: Repository<${className}>,
private authzService: AuthzService,
) {}
/**
* Get all ${name}s
* @returns All ${name}s
*/
async getAll${className}s() {
const { user, ability } = await this.authzService.getDetails();
return await this.${name}Repository.find({
where: {
// locationId: ability.can(
// PermissionAction.MANAGE,
// PERMISSION_ITEM_NAME.CAN_VIEW_ALL_LOCATION_DATA,
// )
// ? undefined // View all if can
// : user.locationId ?? -1, // View none if can't and no locationId
},
order: {
id: 'ASC',
},
});
}
/**
* Get a ${name} by id
* @param id ${className} id
* @returns ${className} found with specific id
*/
async get${className}ById(id: number) {
const ${name} = await this.find${className}OrThrow(id);
// await this.authzService.canAccessOrThrow(
// ${name}.locationId,
// 'locationId',
// PermissionAction.READ,
// PERMISSION_ITEM_NAME.CAN_VIEW_ALL_LOCATION_DATA,
// );
return ${name};
}
/**
* Create a new ${name}
* @param ${name} ${className} to create
* @returns Created ${name}
*/
async create${className}(${name}: ${className}CreateDTO) {
// await this.authzService.canAccessOrThrow(
// ${name}.locationId,
// 'locationId',
// PermissionAction.CREATE,
// PERMISSION_ITEM_NAME.CAN_VIEW_ALL_LOCATION_DATA,
// );
try {
return await this.${name}Repository.save(${name});
} catch (error) {
throw new CustomQueryException(error.message, '${className}');
}
}
/**
* Update a ${name}
* @param ${name} ${className} to update
* @returns Updated ${name}
*/
async patch${className}(id: number, ${name}: ${className}PatchDTO) {
const entity = await this.find${className}OrThrow(id);
// await this.authzService.canAccessOrThrow(
// entity.locationId,
// 'locationId',
// PermissionAction.UPDATE,
// PERMISSION_ITEM_NAME.CAN_VIEW_ALL_LOCATION_DATA,
// );
const updatedEntity = this.${name}Repository.merge(entity, ${name});
try {
return await this.${name}Repository.save(updatedEntity);
} catch (error) {
throw new CustomQueryException(error.message, '${className}');
}
}
/**
* Delete a ${name}
* @param id ${className} id to delete
*/
async delete${className}(id: number) {
const ${name} = await this.find${className}OrThrow(id);
// await this.authzService.canAccessOrThrow(
// ${name}.locationId,
// 'locationId',
// PermissionAction.DELETE,
// PERMISSION_ITEM_NAME.CAN_VIEW_ALL_LOCATION_DATA,
// );
try {
await this.${name}Repository.remove(${name});
} catch (error) {
throw new CustomQueryException(error.message, '${className}');
}
}
/**
* Find ${name} by id or throw
* @param id ${className} id
* @param relations Relations to load
* @returns ${className} found
*/
async find${className}OrThrow(
id: number,
relations?: FindOptionsRelations<${className}>,
): Promise<${className}> {
const ${name} = await this.${name}Repository.findOne({
where: { id },
relations,
});
if (!${name}) {
throw new NotFoundException(\`${className} with id \${id} not found\`);
}
return ${name};
}
}`;
}
function generateModule(entityInfo, entityFilePath, resourceDir) {
const { className, name } = entityInfo;
// Calculate relative path from resource directory to entity file
const relativePath = path.relative(resourceDir, entityFilePath);
const entityImportPath = relativePath.replace(".entity.ts", ".entity");
// Calculate relative path to authz module based on nesting level
const nestingLevel = getEntityNestingLevel(entityFilePath);
const authzRelativePath = "../".repeat(nestingLevel + 1) + "authz/authz.module";
return `import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthzModule } from '${authzRelativePath}';
import { ${className}Service } from './${name}.service';
import { ${className}Controller } from './${name}.controller';
import { ${className} } from '${entityImportPath}';
@Module({
imports: [
TypeOrmModule.forFeature([${className}]),
AuthzModule,
],
controllers: [${className}Controller],
providers: [${className}Service],
exports: [${className}Service],
})
export class ${className}Module {}`;
}
function generateRoutes(entityInfo, entityFilePath) {
const { className, name } = entityInfo;
// Calculate relative path from routes to the module
const entityFullSubPath = getEntityFullSubPath(entityFilePath);
const highestParent = getHighestParentDirectory(entityFilePath);
// Routes file is at: src/routes/{highestParent}/ or src/routes/
// Module file is at: src/{entityFullSubPath}/{name}/
// We need to go up 2 levels from routes/{highestParent}/ to reach src, then down to the module
const moduleRelativePath = entityFullSubPath
? `../../${entityFullSubPath}/${name}/${name}.module`
: `../${name}/${name}.module`;
// Generate nested route structure
const routeStructure = generateNestedRouteStructure(entityInfo, entityFilePath);
const routesVariableName = highestParent ? highestParent.toLowerCase() : name;
console.log(`đ Debug - Entity: ${className}, Highest Parent: '${highestParent}', Routes Variable: '${routesVariableName}ControllerRoutes'`);
return `import { RouteTree, RouterModule } from '@nestjs/core';
import { Module } from '@nestjs/common';
import { ${className}Module } from '${moduleRelativePath}';
const ${routesVariableName}ControllerRoutes: RouteTree[] = [
${routeStructure}
];
@Module({ imports: [RouterModule.register(${routesVariableName}ControllerRoutes)] })
export class ${className}RoutesModule {}`;
}
function generateIndexFile(entityInfo) {
const { className } = entityInfo;
return `export * from './${className.toLowerCase()}.dto';`;
}
function addRoutesToIndex(entityInfo, routesDir, entityFilePath) {
const indexFilePath = path.join(routesDir, "index.ts");
if (!fs.existsSync(indexFilePath)) {
console.log("â ī¸ Warning: routes/index.ts file not found");
return;
}
const indexContent = fs.readFileSync(indexFilePath, "utf-8");
const highestParent = getHighestParentDirectory(entityFilePath);
const routeFileName = highestParent
? highestParent.toLowerCase()
: entityInfo.name;
const relativePath = highestParent
? `./${highestParent}/${routeFileName}.routes`
: `./${routeFileName}.routes`;
const exportStatement = `export * from '${relativePath}';`;
// Check if the export already exists
if (indexContent.includes(exportStatement)) {
console.log("âšī¸ Routes export already exists in routes/index.ts");
return;
}
// Add the export statement at the end of the file
const updatedContent = indexContent.trim() + "\n" + exportStatement + "\n";
fs.writeFileSync(indexFilePath, updatedContent);
console.log(`â
Added routes export to ${indexFilePath}`);
}
function addRoutesToModule(entityInfo, routesDir) {
const moduleFilePath = path.join(routesDir, "routes.module.ts");
if (!fs.existsSync(moduleFilePath)) {
console.log("â ī¸ Warning: routes.module.ts file not found");
return;
}
const moduleContent = fs.readFileSync(moduleFilePath, "utf-8");
const routesModuleName = `${entityInfo.className}RoutesModule`;
const importLine = ` ${routesModuleName},`;
const lines = moduleContent.split("\n");
// Find destructured import block
let importStart = -1;
let importEnd = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith("import {"))
importStart = i;
if (importStart !== -1 && lines[i].includes("} from '.';")) {
importEnd = i;
break;
}
}
if (importStart === -1 || importEnd === -1) {
console.log("â ī¸ Warning: Could not find destructured import block in routes.module.ts");
return;
}
// Check if already in destructured import block
const importBlock = lines.slice(importStart + 1, importEnd);
if (!importBlock.some((line) => line.trim() === importLine.trim())) {
// Insert in alphabetical order
importBlock.push(importLine);
importBlock.sort((a, b) => a.trim().localeCompare(b.trim()));
const newImportBlock = [
lines[importStart],
...importBlock,
lines[importEnd],
];
// Replace old import block
lines.splice(importStart, importEnd - importStart + 1, ...newImportBlock);
}
// Add to imports array in @Module
let importsInsertIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes("ParentPortalRoutesModule,")) {
importsInsertIndex = i + 1;
break;
}
}
if (importsInsertIndex === -1) {
console.log("â ī¸ Warning: Could not find imports array in routes.module.ts");
return;
}
// Only add if not already present
const importsArrayBlock = lines
.slice(Math.max(0, importsInsertIndex - 30), importsInsertIndex + 30)
.join("\n");
if (!importsArrayBlock.includes(importLine.trim())) {
lines.splice(importsInsertIndex, 0, importLine);
}
const updatedContent = lines.join("\n");
fs.writeFileSync(moduleFilePath, updatedContent);
console.log(`â
Added routes module to routes.module.ts`);
}
function addPermissionItemToEnum(entityInfo, entitiesDir) {
const permissionItemPath = path.join(entitiesDir, "PermissionItem.entity.ts");
if (!fs.existsSync(permissionItemPath)) {
console.log("â ī¸ Warning: PermissionItem.entity.ts file not found");
return;
}
const content = fs.readFileSync(permissionItemPath, "utf-8");
const permissionName = entityInfo.className.toUpperCase() + "S";
const permissionValue = entityInfo.className + "s Page";
// Check if the permission already exists (check for both the name and value)
if (content.includes(`${permissionName} =`) ||
content.includes(`'${permissionValue}'`)) {
console.log(`âšī¸ Permission item ${permissionName} already exists in PERMISSION_ITEM_NAME enum`);
return;
}
// Find the last permission item in the enum (before the closing brace)
const lines = content.split("\n");
let insertIndex = -1;
// Look for the last permission item before the closing brace
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].includes("CAN_ASSIGN_CLASS = 'Can Assign Class'")) {
insertIndex = i + 1; // Insert after this line
break;
}
}
if (insertIndex === -1) {
// Fallback: look for the closing brace of the enum
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === "}" &&
i > 0 &&
lines[i - 1].includes("CAN_ASSIGN_CLASS")) {
insertIndex = i;
break;
}
}
}
if (insertIndex === -1) {
console.log("â ī¸ Warning: Could not find insertion point in PERMISSION_ITEM_NAME enum");
return;
}
// Insert the new permission item before the closing brace
const newPermissionLine = ` ${permissionName} = '${permissionValue}',`;
lines.splice(insertIndex, 0, newPermissionLine);
const updatedContent = lines.join("\n");
fs.writeFileSync(permissionItemPath, updatedContent);
console.log(`â
Added permission item ${permissionName} to PERMISSION_ITEM_NAME enum`);
}
function addEntityToIndex(entityInfo, entitiesDir, originalFileName, entityFilePath) {
const indexFilePath = path.join(entitiesDir, "index.ts");
if (!fs.existsSync(indexFilePath)) {
console.log("â ī¸ Warning: index.ts file not found in entities directory");
return;
}
// Calculate relative path from entities index.ts to the entity file
const entityDir = path.dirname(entityFilePath);
const relativePath = path.relative(entitiesDir, entityDir);
const exportPath = relativePath === "."
? `./${originalFileName.replace(".entity.ts", ".entity")}`
: `./${relativePath}/${originalFileName.replace(".entity.ts", ".entity")}`;
const indexContent = fs.readFileSync(indexFilePath, "utf-8");
const exportStatement = `export * from '${exportPath}';`;
// Check if the export already exists
if (indexContent.includes(exportStatement)) {
console.log("âšī¸ Entity export already exists in index.ts");
return;
}
// Add the export statement at the end of the file
const updatedContent = indexContent.trim() + "\n" + exportStatement + "\n";
fs.writeFileSync(indexFilePath, updatedContent);
console.log(`â
Added entity export to ${indexFilePath}`);
}
function createDirectoryIfNotExists(dirPath) {
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
function generateFrontendInterface(entityInfo) {
const { className, fields } = entityInfo;
// Collect relationship types for imports
const relationshipTypes = new Set();
const interfaceFields = fields
.filter((field) => {
// Exclude fields with @Exclude decorator
if (field.decorators.some((d) => d.includes("@Exclude"))) {
return false;
}
// Include all fields, including relational ones
return true;
})
.map((field) => {
// Convert TypeORM types to TypeScript types
let tsType = field.type;
let isOptional = field.isOptional;
// Handle common type conversions
if (tsType.includes("number")) {
if (tsType.includes("[]")) {
tsType = "number[]";
}
else {
tsType = "number";
}
}
else if (tsType.includes("string")) {
tsType = "string";
}
else if (tsType.includes("boolean")) {
tsType = "boolean";
}
else if (tsType.includes("Date")) {
tsType = "Date";
}
else {
// Handle nullable types first
if (tsType.includes("| null")) {
isOptional = true;
tsType = tsType.replace("| null", "");
}
// Handle relationship types - add 'I' prefix and handle arrays
if (tsType.includes("[]")) {
// Array type
const baseType = tsType.replace("[]", "").trim();
tsType = `I${baseType}[]`;
relationshipTypes.add(baseType.trim());
}
else {
// Single relationship type
tsType = `I${tsType}`;
relationshipTypes.add(tsType.replace("I", "").trim());
}
}
return ` ${field.name}${isOptional ? "?" : ""}: ${tsType.trim()};`;
})
.join("\n");
// Generate imports for relationship types
const imports = Array.from(relationshipTypes)
.map((type) => `import { I${type} } from '../I${type}';`)
.join("\n");
const interfaceContent = imports ? `${imports}\n\n` : "";
return `${interfaceContent}export interface I${className} {
${interfaceFields}
}
`;
}
function findFrontendDirectory(entityFilePath) {
const resolved = path.resolve(entityFilePath);
const parts = resolved.split(path.sep);
const srcIdx = parts.lastIndexOf("src");
if (srcIdx === -1)
throw new Error("No src dir in path");
// Go up from src to find the project root (where Backend and Frontend are)
const projectRoot = parts.slice(0, srcIdx - 1).join(path.sep);
const frontendDir = path.join(projectRoot, "Frontend");
return frontendDir;
}
function getEntitySubPath(entityFilePath) {
const resolved = path.resolve(entityFilePath);
const parts = resolved.split(path.sep);
const entitiesIdx = parts.lastIndexOf("entities");
if (entitiesIdx === -1)
return "";
// Get the path from entities to the entity file (excluding the file itself)
const entitySubPath = parts.slice(entitiesIdx + 1, -1).join(path.sep);
return entitySubPath;
}
function getEntityNestingLevel(entityFilePath) {
const resolved = path.resolve(entityFilePath);
const parts = resolved.split(path.sep);
const entitiesIdx = parts.lastIndexOf("entities");
if (entitiesIdx === -1)
return 0;
// Count the number of directories from entities to the entity file
return parts.slice(entitiesIdx + 1, -1).length;
}
function getEntityFullSubPath(entityFilePath) {
const resolved = path.resolve(entityFilePath);
const parts = resolved.split(path.sep);
const entitiesIdx = parts.lastIndexOf("entities");
if (entitiesIdx === -1)
return "";
// Get the full path from entities to the entity file (excluding the file itself)
const entitySubPath = parts.slice(entitiesIdx + 1, -1).join(path.sep);
return entitySubPath;
}
function getEntityParentDirectories(entityFilePath) {
const resolved = path.resolve(entityFilePath);
const parts = resolved.split(path.sep);
const entitiesIdx = parts.lastIndexOf("entities");
if (entitiesIdx === -1)
return [];
// Get all parent directories from entities to the entity file (excluding the file itself)
return parts.slice(entitiesIdx + 1, -1);
}
function getHighestParentDirectory(entityFilePath) {
const parentDirs = getEntityParentDirectories(entityFilePath);
return parentDirs.length > 0 ? parentDirs[0] : "";
}
function generateNestedRouteStructure(entityInfo, entityFilePath) {
const parentDirs = getEntityParentDirectories(entityFilePath);
const { className, name } = entityInfo;
if (parentDirs.length === 0) {
// No parent directories, simple route
return `{
path: '${name}s',
module: ${className}Module,
}`;
}
// Build nested structure from bottom up
let currentLevel = `{
path: '${name}s',
module: ${className}Module,
}`;
// Add parent directories from bottom to top
for (let i = parentDirs.length - 1; i >= 0; i--) {
const parentDir = parentDirs[i].toLowerCase();
currentLevel = `{
path: '${parentDir}',
children: [
${currentLevel}
],
}`;
}
return currentLevel;
}
function createFrontendInterface(entityInfo, entityFilePath) {
// Calculate path to Frontend directory using the same logic as Backend
const frontendDir = findFrontendDirectory(entityFilePath);
const entitySubPath = getEntitySubPath(entityFilePath);
const frontendInterfacesDir = path.join(frontendDir, "src", "app", "shared", "interfaces", entitySubPath);
// Create interfaces directory if it doesn't exist
if (!fs.existsSync(frontendInterfacesDir)) {
fs.mkdirSync(frontendInterfacesDir, { recursive: true });
}
const interfaceContent = generateFrontendInterface(entityInfo);
const interfaceFilePath = path.join(frontendInterfacesDir, `I${entityInfo.className}.interface.ts`);
fs.writeFileSync(interfaceFilePath, interfaceContent);
console.log(`â
Created Frontend interface: ${interfaceFilePath}`);
}
function addFrontendInterfaceToIndex(entityInfo, entityFilePath) {
const frontendDir = findFrontendDirectory(entityFilePath);
const entitySubPath = getEntitySubPath(entityFilePath);
const rootInterfacesDir = path.join(frontendDir, "src", "app", "shared", "interfaces");
const indexFilePath = path.join(rootInterfacesDir, "index.ts");
// Calculate relative path from root interfaces to the nested interface file
const relativePath = entitySubPath
? `./${entitySubPath}/I${entityInfo.className}.interface`
: `./I${entityInfo.className}.interface`;
const exportStatement = `export * from '${relativePath}';`;
if (!fs.existsSync(indexFilePath)) {
fs.writeFileSync(indexFilePath, exportStatement + "\n");
console.log(`â
Created index.ts and added export for ${entityInfo.name}.interface.ts`);
return;
}
const indexContent = fs.readFileSync(indexFilePath, "utf-8");
if (indexContent.includes(exportStatement)) {
console.log(`âšī¸ Interface export already exists in interfaces/index.ts`);
return;
}
fs.appendFileSync(indexFilePath, exportStatement + "\n");
console.log(`â
Added interface export to interfaces/index.ts`);
}
function generateFrontendService(entityInfo, entityFilePath) {
const { className, name } = entityInfo;
const interfaceName = `I${className}`;
// Calculate relative path from service to interface based on nesting level
const entitySubPath = getEntitySubPath(entityFilePath);
const nestingLevel = getEntityNestingLevel(entityFilePath);
// Calculate the number of "../" needed to go up from the service directory to the shared directory
// Service is at: Frontend/src/app/shared/services/{entitySubPath}/
// Interface is at: Frontend/src/app/shared/interfaces/{entitySubPath}/
// We need to go up (nestingLevel + 1) levels to reach shared, then down to interfaces
const upLevels = nestingLevel + 1;
const upPath = "../".repeat(upLevels);
const interfacePath = entitySubPath
? `${upPath}interfaces/${entitySubPath}/I${className}.interface`
: `${upPath}interfaces/I${className}.interface`;
const interfaceImport = `import { ${interfaceName} } from '${interfacePath}';`;
// Build the API URL path based on entity subdirectory structure
// Convert directories to lowercase for API URL
const apiUrlPath = entitySubPath
? `${entitySubPath.toLowerCase()}/${name}`
: name;
// Calculate the path to environments directory
// Service is at: Frontend/src/app/shared/services/{entitySubPath}/
// Environments is at: Frontend/src/environments/
// We need to go up (nestingLevel + 4) levels to reach src, then down to environments
const envUpLevels = nestingLevel + 3;
const envPath = "../".repeat(envUpLevels);
console.log(`đ Debug - Entity: ${entityInfo.className}, Nesting Level: ${nestingLevel}, Env Up Levels: ${envUpLevels}, Env Path: '${envPath}'`);
return `import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { environment } from '${envPath}environments/environment';
${interfaceImport}
@Injectable({ providedIn: 'root' })
export class ${className}Service {
private readonly apiUrl = \`\${environment.baseApiUrl}/${apiUrlPath}\`;
constructor(private http: HttpClient) {}
/**
* Get all ${name}s
* @returns Observable of ${name} array
*/
getAll(): Observable<${interfaceName}[]> {
return this.http.get<${interfaceName}[]>(this.apiUrl);
}
/**
* Get a ${name} by id
* @param id ${className} id
* @returns Observable of ${name}
*/
getById(id: number): Observable<${interfaceName}> {
return this.http.get<${interfaceName}>(this.apiUrl + '/' + id);
}
/**
* Create a new ${name}
* @param data ${className} data to create
* @returns Observable of created ${name}
*/
create(data: Partial<${interfaceName}>): Observable<${interfaceName}> {
return this.http.post<${interfaceName}>(this.apiUrl, data);
}
/**
* Update a ${name}
* @param id ${className} id to update
* @param data ${className} data to update
* @returns Observable of updated ${name}
*/
patch(id: number, data: Partial<${interfaceName}>): Observable<${interfaceName}> {
return this.http.patch<${interfaceName}>(this.apiUrl + '/' + id, data);
}
/**
* Delete a ${name}
* @param id ${className} id to delete
* @returns Observable of void
*/
delete(id: number): Observable<void> {
return this.http.delete<void>(this.apiUrl + '/' + id);
}
}
`;
}
function addFrontendServiceToIndex(entityInfo, entityFilePath) {
const frontendDir = findFrontendDirectory(entityFilePath);
const entitySubPath = getEntitySubPath(entityFilePath);
const rootServicesDir = path.join(frontendDir, "src", "app", "shared", "services");
const indexFilePath = path.join(rootServicesDir, "index.ts");
// Calculate relative path from root services to the nested service file
const relativePath = entitySubPath
? `./${entitySubPath}/${entityInfo.name}.service`
: `./${entityInfo.name}.service`;
const exportStatement = `export * from '${relativePath}';`;
if (!fs.existsSync(indexFilePath)) {
fs.writeFileSync(indexFilePath, exportStatement + "\n");
console.log(`â
Created index.ts and added export for ${entityInfo.name}.service.ts`);
return;
}
const indexContent = fs.readFileSync(indexFilePath, "utf-8");
if (indexContent.includes(exportStatement)) {
console.log(`âšī¸ Service export already exists in services/index.ts`);
return;
}
fs.appendFileSync(indexFilePath, exportStatement + "\n");
console.log(`â
Added service export to services/index.ts`);
}
function createFrontendService(entityInfo, entityFilePath) {
const frontendDir = findFrontendDirectory(entityFilePath);
const entitySubPath = getEntitySubPath(entityFilePath);
const frontendServicesDir = path.join(frontendDir, "src", "app", "shared", "services", entitySubPath);
if (!fs.existsSync(frontendServicesDir)) {
fs.mkdirSync(frontendServicesDir, { recursive: true });
}
const serviceFilePath = path.join(frontendServicesDir, `${entityInfo.name}.service.ts`);
if (fs.existsSync(serviceFilePath)) {
console.log(`âšī¸ Service file already exists: ${serviceFilePath}`);
return;
}
const serviceContent = generateFrontendService(entityInfo, entityFilePath);
fs.writeFileSync(serviceFilePath, serviceContent);
console.log(`â
Created Frontend service: ${serviceFilePath}`);
// Add service to index file
addFrontendServiceToIndex(entityInfo, entityFilePath);
}
function runFormatCommands(entityFilePath) {
try {
// Calculate paths to Backend and Frontend directories
const backendDir = path.join(path.dirname(entityFilePath), "..", "..", "..");
const frontendDir = path.join(backendDir, "..", "Frontend");
// Check if Backend directory exists and has package.json
const backendPackageJson = path.join(backendDir, "package.json");
if (fs.existsSync(backendPackageJson)) {
console.log("đ Running format command on Backend package.json...");
const { execSync } = require("child_process");
try {
execSync("npm run format", {
cwd: backendDir,
stdio: "inherit",
});
console.log("â
Backend package.json formatted successfully");
}
catch (error) {
console.log("â ī¸ Warning: Could not run format command on Backend package.json");
}
}
else {
console.log("â ī¸ Warning: Backend package.json not found");
}
// Check if Frontend directory exists and has package.json
const frontendPackageJson = path.join(frontendDir, "package.json");
if (fs.existsSync(frontendPackageJson)) {
console.log("đ Running format command on Frontend package.json...");
const { execSync } = require("child_process");
try {
execSync("npm run format", {
cwd: frontendDir,
stdio: "inherit",
});
console.log("â
Frontend package.json formatted successfully");
}
catch (error) {
console.log("â ī¸ Warning: Could not run format command on Frontend package.json");
}
}
else {
console.log("â ī¸ Warning: Frontend package.json not found");
}
}
catch (error) {
console.log("â ī¸ Warning: Error running format commands:", error instanceof Error ? error.message : String(error));
}
}
function findPermissionItemFile(entitiesDir) {
const permissionItemPath = path.join(entitiesDir, "PermissionItem.entity.ts");
if (fs.existsSync(permissionItemPath)) {
return permissionItemPath;
}
// Search recursively in subdirectories
const searchPermissionItem = (dir) => {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
const found = searchPermissionItem(fullPath);
if (found)
return found;
}
else if (item === "PermissionItem.entity.ts") {
return fullPath;
}
}
return null;
};
return searchPermissionItem(entitiesDir);
}
function findEntitiesIndexFile(entitiesDir) {
const indexPath = path.join(entitiesDir, "index.ts");
if (fs.existsSync(indexPath)) {
return indexPath;
}
// Search recursively in subdirectories
const searchIndex = (dir) => {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
const found = searchIndex(fullPath);
if (found)
return found;
}
else if (item === "index.ts") {
return fullPath;
}
}
return null;
};
return searchIndex(entitiesDir);
}
function findAppModuleFile(srcDir) {
const appModulePath = path.join(srcDir, "app.module.ts");
if (fs.existsSync(appModulePath)) {
return appModulePath;
}
// Search recursively in subdirectories
const searchAppModule = (dir) => {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
const found = searchAppModule(fullPath);
if (found)
return found;
}
else if (item === "app.module.ts") {
return fullPath;
}
}
return null;
};
return searchAppModule(srcDir);
}
function addModuleToAppModule(entityInfo, srcDir, resourceDir) {
const appModulePath = findAppModuleFile(srcDir);
if (!appModulePath) {
console.log("â ī¸ Warning: app.module.ts file not found");
return;
}
const appModuleContent = fs.readFileSync(appModulePath, "utf-8");
const { className, name } = entityInfo;
// Calculate relative path from app.module.ts to the new module
const relativePath = path.relative(path.dirname(appModulePath), resourceDir);
const moduleImportPath = `./${relativePath}/${name}.module`;
const moduleClassName = `${className}Module`;
// Check if the import already exists
const importStatement = `import { ${moduleClassName} } from '${moduleImportPath}';`;
if (appModuleContent.includes(importStatement)) {
console.log(`âšī¸ Module import already exists in app.module.ts`);
return;
}
// Add the import statement
const lines = appModuleContent.split("\n");
let importInsertIndex = -1;
// Find the last import statement
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].trim().startsWith("import ")) {
importInsertIndex = i + 1;
break;
}
}
if (importInsertIndex === -1) {
console.log("â ī¸ Warning: Could not find import statements in app.module.ts");
return;
}
// Insert the new import
lines.splice(importInsertIndex, 0, importStatement);
// Find the main @Module decorator's imports array
let moduleDecoratorFound = false;
let importsArrayStart = -1;
let importsArrayEnd = -1;
let bracketCount = 0;
let inImportsArray = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Find the start of @Module decorator
if (line.startsWith("@Module(")) {
moduleDecoratorFound = true;
bracketCount = 1;
continue;
}
// Only look for imports after we've found @Module
if (moduleDecoratorFound) {
// Count brackets to track nesting level
for (const char of line) {
if (char === "{")
bracketCount++;
if (char === "}")
bracketCount--;
}
// Look for the first imports: [ (not nested)
if (line.includes("imports:") &&
line.includes("[") &&
bracketCount === 1) {
importsArrayStart = i;
inImportsArray = true;
if (line.includes("]")) {
importsArrayEnd = i;
break;
}
}
else if (inImportsArray && line.includes("]") && bracketCount === 1) {
importsArrayEnd = i;
break;
}
// If we've closed the @Module decorator, stop searching
if (bracketCount === 0)
break;
}
}
if (importsArrayStart === -1 || importsArrayEnd === -1) {
console.log("â ī¸ Warning: Could not find imports array in main @Module decorator");
return;
}
// Check if module is already in imports array
const importsArrayContent = lines
.slice(importsArrayStart, importsArrayEnd + 1)
.join("\n");
if (importsArrayContent.includes(moduleClassName)) {
console.log(`âšī¸ Module already exists in imports array`);
return;
}
// Add the module to the imports array (as the last element)
// Find the actual last element in the imports array
let lastElementIndex = importsArrayEnd;
let lastElementLine = lines[importsArrayEnd];
// Look backwards to find the last non-empty element
for (let i = importsArrayEnd - 1; i >= importsArrayStart; i--) {
const line = lines[i].trim();
if (line && !line.startsWith("//") && !line.startsWith("/*")) {
lastElementIndex = i;
lastElementLine = lines[i];
break;
}
}
// Check if the last element already ends with a comma
const trimmedLastElement = lastElementLine.trim();
const needsComma = !trimmedLastElement.endsWith(",") && !trimmedLastElement.endsWith("[");
let newImportLine;
if (needsComma) {
newImportLine = lastElementLine.replace(/,$/, "").replace(/$/, ",");
lines[lastElementIndex] = newImportLine;
}
// Add the new module on a new line before the closing bracket
const newModuleLine = ` ${moduleClassName},`;
lines.splice(importsArrayEnd, 0, newModuleLine);
const updatedContent = lines.join("\n");
fs.writeFileSync(appModulePath, updatedContent);
console.log(`â
Added ${moduleClassName} to app.module.ts`);
}
function addRouteToExistingFile(entityInfo, entityFilePath, routeFileDir, routeFileName, newRoutesContent) {
const routeFilePath = path.join(routeFileDir, routeFileName);
if (!fs.existsSync(routeFilePath)) {
// File doesn't exist, create it with the new content
fs.writeFileSync(routeFilePath, newRoutesContent);
console.log(`â
Created new routes file: ${routeFilePath}`);
return;
}
// File exists, merge the new route with existing routes
let content = fs.readFileSync(routeFilePath, "utf-8");
// Add new import if it doesn't exist
const newImport = `import { ${entityInfo.className}Module } from '../../${getEntityFullSubPath(entityFilePath)}/${entityInfo.name}/${entityInfo.name}.module';`;
if (!content.includes(entityInfo.className + "Module")) {
// Find the last import and add the new one
const lines = content.split("\n");
let lastImportIndex = -1;
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().startsWith("import ")) {
lastImportIndex = i;
}
}
if (lastImportIndex !== -1) {
lines.splice(lastImportIndex + 1, 0, newImport);
content = lines.join("\n");
}
}
// Generate the new route object
const newRouteObject = ` {
path: '${entityInfo.name}s',
module: ${entityInfo.className}Module,
}`;
// Find the children array and add the new route
const lines = content.split("\n");
let childrenStartIndex = -1;
let childrenEndIndex = -1;
let braceCount = 0;
let inChildrenArray = false;
// Find the children array boundaries
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.includes("children: [")) {
inChildrenArray = true;
childrenStartIndex = i;
braceCount = 0;
}
else if (inChildrenArray) {
if (line.includes("{"))
braceCount++;
if (