create-forgeon 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +11 -0
  2. package/bin/create-forgeon.mjs +265 -0
  3. package/package.json +16 -0
  4. package/templates/base/.editorconfig +11 -0
  5. package/templates/base/README.md +56 -0
  6. package/templates/base/apps/api/Dockerfile +23 -0
  7. package/templates/base/apps/api/package.json +40 -0
  8. package/templates/base/apps/api/prisma/migrations/0001_init/migration.sql +12 -0
  9. package/templates/base/apps/api/prisma/migrations/migration_lock.toml +1 -0
  10. package/templates/base/apps/api/prisma/schema.prisma +15 -0
  11. package/templates/base/apps/api/prisma/seed.ts +20 -0
  12. package/templates/base/apps/api/src/app.module.ts +34 -0
  13. package/templates/base/apps/api/src/common/dto/echo-query.dto.ts +6 -0
  14. package/templates/base/apps/api/src/common/filters/app-exception.filter.ts +130 -0
  15. package/templates/base/apps/api/src/config/app.config.ts +13 -0
  16. package/templates/base/apps/api/src/health/health.controller.ts +31 -0
  17. package/templates/base/apps/api/src/main.ts +26 -0
  18. package/templates/base/apps/api/src/prisma/prisma.module.ts +9 -0
  19. package/templates/base/apps/api/src/prisma/prisma.service.ts +27 -0
  20. package/templates/base/apps/api/tsconfig.build.json +9 -0
  21. package/templates/base/apps/api/tsconfig.json +9 -0
  22. package/templates/base/apps/web/Dockerfile +13 -0
  23. package/templates/base/apps/web/index.html +13 -0
  24. package/templates/base/apps/web/package.json +23 -0
  25. package/templates/base/apps/web/src/App.tsx +37 -0
  26. package/templates/base/apps/web/src/main.tsx +9 -0
  27. package/templates/base/apps/web/src/styles.css +33 -0
  28. package/templates/base/apps/web/tsconfig.json +18 -0
  29. package/templates/base/apps/web/vite.config.ts +15 -0
  30. package/templates/base/docs/AI/ARCHITECTURE.md +38 -0
  31. package/templates/base/docs/AI/PROJECT.md +32 -0
  32. package/templates/base/docs/AI/TASKS.md +48 -0
  33. package/templates/base/docs/README.md +5 -0
  34. package/templates/base/infra/docker/.env.example +10 -0
  35. package/templates/base/infra/docker/compose.yml +45 -0
  36. package/templates/base/infra/docker/nginx.Dockerfile +16 -0
  37. package/templates/base/infra/nginx/nginx.conf +32 -0
  38. package/templates/base/package.json +24 -0
  39. package/templates/base/packages/core/README.md +4 -0
  40. package/templates/base/packages/core/package.json +14 -0
  41. package/templates/base/packages/core/src/index.ts +1 -0
  42. package/templates/base/packages/core/tsconfig.json +8 -0
  43. package/templates/base/packages/i18n/package.json +19 -0
  44. package/templates/base/packages/i18n/src/forgeon-i18n.module.ts +47 -0
  45. package/templates/base/packages/i18n/src/index.ts +2 -0
  46. package/templates/base/packages/i18n/tsconfig.json +9 -0
  47. package/templates/base/pnpm-workspace.yaml +3 -0
  48. package/templates/base/resources/i18n/en/common.json +5 -0
  49. package/templates/base/resources/i18n/en/validation.json +3 -0
  50. package/templates/base/resources/i18n/uk/common.json +5 -0
  51. package/templates/base/resources/i18n/uk/validation.json +3 -0
  52. package/templates/base/tsconfig.base.json +17 -0
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # create-forgeon
2
+
3
+ CLI package for generating Forgeon fullstack monorepo projects.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx create-forgeon@latest my-app --frontend react --db prisma --i18n true --docker true
9
+ ```
10
+
11
+ If flags are omitted, the CLI asks interactive questions.
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline/promises';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { stdin as input, stdout as output } from 'node:process';
8
+
9
+ const SUPPORTED_FRONTENDS = ['react', 'angular'];
10
+ const SUPPORTED_DBS = ['prisma'];
11
+
12
+ function printHelp() {
13
+ console.log(`create-forgeon
14
+
15
+ Usage:
16
+ npx create-forgeon@latest <project-name> [options]
17
+
18
+ Options:
19
+ --frontend <react|angular> Frontend preset (default: react)
20
+ --db <prisma> DB preset (default: prisma)
21
+ --i18n <true|false> Enable i18n (default: true)
22
+ --docker <true|false> Include docker/infra files (default: true)
23
+ --install Run pnpm install after generation
24
+ -y, --yes Skip prompts and use defaults
25
+ -h, --help Show this help
26
+ `);
27
+ }
28
+
29
+ function parseBoolean(value, fallback) {
30
+ if (value === undefined) return fallback;
31
+ if (typeof value === 'boolean') return value;
32
+
33
+ const normalized = String(value).trim().toLowerCase();
34
+ if (['true', '1', 'yes', 'y'].includes(normalized)) return true;
35
+ if (['false', '0', 'no', 'n'].includes(normalized)) return false;
36
+
37
+ throw new Error(`Invalid boolean value: ${value}`);
38
+ }
39
+
40
+ function toKebabCase(value) {
41
+ return value
42
+ .trim()
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '') || 'forgeon-app';
46
+ }
47
+
48
+ function copyRecursive(source, destination) {
49
+ const stat = fs.statSync(source);
50
+
51
+ if (stat.isDirectory()) {
52
+ fs.mkdirSync(destination, { recursive: true });
53
+ for (const entry of fs.readdirSync(source)) {
54
+ copyRecursive(path.join(source, entry), path.join(destination, entry));
55
+ }
56
+ return;
57
+ }
58
+
59
+ fs.copyFileSync(source, destination);
60
+ }
61
+
62
+ function writeJson(filePath, data) {
63
+ fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
64
+ }
65
+
66
+ async function main() {
67
+ const args = process.argv.slice(2);
68
+ const options = {
69
+ name: undefined,
70
+ frontend: undefined,
71
+ db: undefined,
72
+ i18n: undefined,
73
+ docker: undefined,
74
+ install: false,
75
+ yes: false,
76
+ help: false,
77
+ };
78
+
79
+ const positional = [];
80
+
81
+ for (let i = 0; i < args.length; i += 1) {
82
+ const arg = args[i];
83
+
84
+ if (arg === '--') {
85
+ continue;
86
+ }
87
+
88
+ if (arg === '-h' || arg === '--help') {
89
+ options.help = true;
90
+ continue;
91
+ }
92
+
93
+ if (arg === '-y' || arg === '--yes') {
94
+ options.yes = true;
95
+ continue;
96
+ }
97
+
98
+ if (arg === '--install') {
99
+ options.install = true;
100
+ continue;
101
+ }
102
+
103
+ if (arg.startsWith('--no-')) {
104
+ const key = arg.slice(5);
105
+ if (key === 'install') options.install = false;
106
+ if (key === 'docker') options.docker = false;
107
+ if (key === 'i18n') options.i18n = false;
108
+ continue;
109
+ }
110
+
111
+ if (arg.startsWith('--')) {
112
+ const [keyRaw, inlineValue] = arg.split('=');
113
+ const key = keyRaw.slice(2);
114
+
115
+ let value = inlineValue;
116
+ if (value === undefined && args[i + 1] && !args[i + 1].startsWith('-')) {
117
+ value = args[i + 1];
118
+ i += 1;
119
+ }
120
+
121
+ if (key in options) {
122
+ options[key] = value;
123
+ }
124
+
125
+ continue;
126
+ }
127
+
128
+ positional.push(arg);
129
+ }
130
+
131
+ if (options.help) {
132
+ printHelp();
133
+ return;
134
+ }
135
+
136
+ if (!options.name && positional.length > 0) {
137
+ options.name = positional[0];
138
+ }
139
+
140
+ const rl = readline.createInterface({ input, output });
141
+
142
+ if (!options.name) {
143
+ options.name = await rl.question('Project name: ');
144
+ }
145
+
146
+ if (!options.yes && !options.frontend) {
147
+ options.frontend =
148
+ (await rl.question('Frontend (react/angular) [react]: ')) || 'react';
149
+ }
150
+
151
+ if (!options.yes && !options.db) {
152
+ options.db = (await rl.question('DB preset [prisma]: ')) || 'prisma';
153
+ }
154
+
155
+ if (!options.yes && options.i18n === undefined) {
156
+ options.i18n = (await rl.question('Enable i18n (true/false) [true]: ')) || 'true';
157
+ }
158
+
159
+ if (!options.yes && options.docker === undefined) {
160
+ options.docker =
161
+ (await rl.question('Include Docker/infra (true/false) [true]: ')) || 'true';
162
+ }
163
+
164
+ await rl.close();
165
+
166
+ if (!options.name || options.name.trim().length === 0) {
167
+ console.error('Project name is required.');
168
+ process.exit(1);
169
+ }
170
+
171
+ const frontendRaw = (options.frontend ?? 'react').toString().toLowerCase();
172
+ const dbRaw = (options.db ?? 'prisma').toString().toLowerCase();
173
+ const i18nEnabled = parseBoolean(options.i18n, true);
174
+ const dockerEnabled = parseBoolean(options.docker, true);
175
+
176
+ const frontend = SUPPORTED_FRONTENDS.includes(frontendRaw) ? frontendRaw : 'react';
177
+ if (frontendRaw !== frontend) {
178
+ console.warn(`Unsupported frontend "${frontendRaw}". Falling back to "react".`);
179
+ }
180
+ if (frontend === 'angular') {
181
+ console.warn('Angular preset is planned, but not implemented yet. Falling back to React.');
182
+ }
183
+
184
+ const db = SUPPORTED_DBS.includes(dbRaw) ? dbRaw : 'prisma';
185
+ if (dbRaw !== db) {
186
+ console.warn(`Unsupported db preset "${dbRaw}". Falling back to "prisma".`);
187
+ }
188
+
189
+ const projectName = options.name.trim();
190
+ const targetRoot = path.resolve(process.cwd(), projectName);
191
+
192
+ if (fs.existsSync(targetRoot)) {
193
+ console.error(`Target directory already exists: ${targetRoot}`);
194
+ process.exit(1);
195
+ }
196
+
197
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
198
+ const templateRoot = path.resolve(scriptDir, '..', 'templates', 'base');
199
+
200
+ copyRecursive(templateRoot, targetRoot);
201
+
202
+ const rootPackageJsonPath = path.join(targetRoot, 'package.json');
203
+ const rootPackageJson = JSON.parse(fs.readFileSync(rootPackageJsonPath, 'utf8'));
204
+ rootPackageJson.name = toKebabCase(projectName);
205
+
206
+ if (rootPackageJson.scripts) {
207
+ delete rootPackageJson.scripts['create:forgeon'];
208
+
209
+ if (!dockerEnabled) {
210
+ delete rootPackageJson.scripts['docker:up'];
211
+ delete rootPackageJson.scripts['docker:down'];
212
+ }
213
+ }
214
+
215
+ writeJson(rootPackageJsonPath, rootPackageJson);
216
+
217
+ if (!dockerEnabled) {
218
+ fs.rmSync(path.join(targetRoot, 'infra'), { recursive: true, force: true });
219
+ } else {
220
+ const envExamplePath = path.join(targetRoot, 'infra', 'docker', '.env.example');
221
+ if (fs.existsSync(envExamplePath)) {
222
+ const current = fs.readFileSync(envExamplePath, 'utf8');
223
+ const next = current
224
+ .replace(/I18N_ENABLED=.*/g, `I18N_ENABLED=${i18nEnabled}`)
225
+ .replace(/I18N_DEFAULT_LANG=.*/g, 'I18N_DEFAULT_LANG=en')
226
+ .replace(/I18N_FALLBACK_LANG=.*/g, 'I18N_FALLBACK_LANG=en');
227
+ fs.writeFileSync(envExamplePath, next, 'utf8');
228
+ }
229
+ }
230
+
231
+ const apiEnvExamplePath = path.join(targetRoot, 'apps', 'api', '.env.example');
232
+ const apiEnv = [
233
+ 'PORT=3000',
234
+ 'DATABASE_URL=postgresql://postgres:postgres@localhost:5432/app?schema=public',
235
+ `I18N_ENABLED=${i18nEnabled}`,
236
+ 'I18N_DEFAULT_LANG=en',
237
+ 'I18N_FALLBACK_LANG=en',
238
+ ].join('\n');
239
+ fs.writeFileSync(apiEnvExamplePath, `${apiEnv}\n`, 'utf8');
240
+
241
+ if (parseBoolean(options.install, false)) {
242
+ const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
243
+ const result = spawnSync(pnpmCmd, ['install'], {
244
+ cwd: targetRoot,
245
+ stdio: 'inherit',
246
+ shell: false,
247
+ });
248
+
249
+ if (result.status !== 0) {
250
+ process.exit(result.status ?? 1);
251
+ }
252
+ }
253
+
254
+ console.log('Forgeon scaffold generated.');
255
+ console.log(`- path: ${targetRoot}`);
256
+ console.log(`- frontend: ${frontend}`);
257
+ console.log(`- db: ${db}`);
258
+ console.log(`- i18n: ${i18nEnabled}`);
259
+ console.log(`- docker: ${dockerEnabled}`);
260
+ }
261
+
262
+ main().catch((error) => {
263
+ console.error(error instanceof Error ? error.message : error);
264
+ process.exit(1);
265
+ });
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "create-forgeon",
3
+ "version": "0.0.1",
4
+ "description": "Forgeon project generator CLI",
5
+ "license": "MIT",
6
+ "author": "Forgeon",
7
+ "type": "module",
8
+ "bin": {
9
+ "create-forgeon": "bin/create-forgeon.mjs"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "templates",
14
+ "README.md"
15
+ ]
16
+ }
@@ -0,0 +1,11 @@
1
+ root = true
2
+
3
+ [*.{ts,tsx,js,mjs,cjs,json,md,yml,yaml,css,html,prisma,sql,conf}]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ indent_style = space
8
+ indent_size = 2
9
+
10
+ [Makefile]
11
+ indent_style = tab
@@ -0,0 +1,56 @@
1
+ # Forgeon Fullstack Scaffold
2
+
3
+ Canonical monorepo scaffold for NestJS + frontend with shared packages, built-in docs, optional i18n (enabled by default), and default DB stack Prisma + Postgres.
4
+
5
+ ## Quick Start (Dev)
6
+
7
+ 1. Install dependencies:
8
+ ```bash
9
+ pnpm install
10
+ ```
11
+ 2. Start local Postgres (Docker):
12
+ ```bash
13
+ docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up db -d
14
+ ```
15
+ 3. Run API + web in dev mode:
16
+ ```bash
17
+ pnpm dev
18
+ ```
19
+ 4. Open:
20
+ - Web: `http://localhost:5173`
21
+ - API health: `http://localhost:3000/api/health`
22
+
23
+ ## Quick Start (Docker)
24
+
25
+ ```bash
26
+ docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up --build
27
+ ```
28
+
29
+ Open `http://localhost:8080`.
30
+
31
+ ## i18n Toggle
32
+
33
+ Set in env:
34
+ - `I18N_ENABLED=true|false`
35
+ - `I18N_DEFAULT_LANG=en`
36
+ - `I18N_FALLBACK_LANG=en`
37
+
38
+ When `I18N_ENABLED=false`, API runs without loading i18n module.
39
+
40
+ ## Prisma In Docker Start
41
+
42
+ API container starts with:
43
+ 1. `prisma migrate deploy`
44
+ 2. `node apps/api/dist/main.js`
45
+
46
+ This keeps container startup production-like while still simple.
47
+
48
+ ## Generator Command
49
+
50
+ Use:
51
+ ```bash
52
+ pnpm create:forgeon -- --name my-app --frontend react --db prisma --i18n true
53
+ ```
54
+
55
+ If flags are omitted, script asks questions interactively.
56
+
@@ -0,0 +1,23 @@
1
+ FROM node:20-alpine
2
+
3
+ WORKDIR /app
4
+ RUN corepack enable
5
+
6
+ COPY package.json pnpm-workspace.yaml tsconfig.base.json ./
7
+ COPY apps/api/package.json apps/api/package.json
8
+ COPY packages/core/package.json packages/core/package.json
9
+ COPY packages/i18n/package.json packages/i18n/package.json
10
+
11
+ RUN pnpm install --frozen-lockfile=false
12
+
13
+ COPY apps/api apps/api
14
+ COPY packages/core packages/core
15
+ COPY packages/i18n packages/i18n
16
+ COPY resources resources
17
+
18
+ RUN pnpm --filter @forgeon/i18n build
19
+ RUN pnpm --filter @forgeon/api prisma:generate
20
+ RUN pnpm --filter @forgeon/api build
21
+
22
+ EXPOSE 3000
23
+ CMD ["sh", "-c", "pnpm --filter @forgeon/api prisma:migrate:deploy && node apps/api/dist/main.js"]
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@forgeon/api",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "predev": "pnpm --filter @forgeon/i18n build",
7
+ "build": "tsc -p tsconfig.build.json",
8
+ "dev": "ts-node --transpile-only src/main.ts",
9
+ "start": "node dist/main.js",
10
+ "prisma:generate": "prisma generate --schema prisma/schema.prisma",
11
+ "prisma:migrate:dev": "prisma migrate dev --schema prisma/schema.prisma",
12
+ "prisma:migrate:deploy": "prisma migrate deploy --schema prisma/schema.prisma",
13
+ "prisma:studio": "prisma studio --schema prisma/schema.prisma",
14
+ "prisma:seed": "ts-node --transpile-only prisma/seed.ts"
15
+ },
16
+ "dependencies": {
17
+ "@forgeon/i18n": "workspace:*",
18
+ "@nestjs/common": "^11.0.1",
19
+ "@nestjs/config": "^4.0.2",
20
+ "@nestjs/core": "^11.0.1",
21
+ "@nestjs/platform-express": "^11.0.1",
22
+ "@prisma/client": "^6.18.0",
23
+ "class-transformer": "^0.5.1",
24
+ "class-validator": "^0.14.1",
25
+ "nestjs-i18n": "^10.5.1",
26
+ "reflect-metadata": "^0.2.2",
27
+ "rxjs": "^7.8.1"
28
+ },
29
+ "devDependencies": {
30
+ "@types/express": "^5.0.0",
31
+ "@types/node": "^22.10.7",
32
+ "prisma": "^6.18.0",
33
+ "ts-node": "^10.9.2",
34
+ "typescript": "^5.7.3"
35
+ },
36
+ "prisma": {
37
+ "seed": "ts-node --transpile-only prisma/seed.ts"
38
+ }
39
+ }
40
+
@@ -0,0 +1,12 @@
1
+ -- CreateTable
2
+ CREATE TABLE "User" (
3
+ "id" TEXT NOT NULL,
4
+ "email" TEXT NOT NULL,
5
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
6
+ "updatedAt" TIMESTAMP(3) NOT NULL,
7
+
8
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
9
+ );
10
+
11
+ -- CreateIndex
12
+ CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
@@ -0,0 +1 @@
1
+ provider = "postgresql"
@@ -0,0 +1,15 @@
1
+ generator client {
2
+ provider = "prisma-client-js"
3
+ }
4
+
5
+ datasource db {
6
+ provider = "postgresql"
7
+ url = env("DATABASE_URL")
8
+ }
9
+
10
+ model User {
11
+ id String @id @default(cuid())
12
+ email String @unique
13
+ createdAt DateTime @default(now())
14
+ updatedAt DateTime @updatedAt
15
+ }
@@ -0,0 +1,20 @@
1
+ import { PrismaClient } from '@prisma/client';
2
+
3
+ const prisma = new PrismaClient();
4
+
5
+ async function main() {
6
+ await prisma.user.upsert({
7
+ where: { email: 'seed@example.com' },
8
+ update: {},
9
+ create: { email: 'seed@example.com' },
10
+ });
11
+ }
12
+
13
+ main()
14
+ .catch((error) => {
15
+ console.error(error);
16
+ process.exit(1);
17
+ })
18
+ .finally(async () => {
19
+ await prisma.$disconnect();
20
+ });
@@ -0,0 +1,34 @@
1
+ import { Module } from '@nestjs/common';
2
+ import { ConfigModule } from '@nestjs/config';
3
+ import { ForgeonI18nModule } from '@forgeon/i18n';
4
+ import { join } from 'path';
5
+ import appConfig from './config/app.config';
6
+ import { HealthController } from './health/health.controller';
7
+ import { PrismaModule } from './prisma/prisma.module';
8
+ import { AppExceptionFilter } from './common/filters/app-exception.filter';
9
+
10
+ const i18nEnabled = (process.env.I18N_ENABLED ?? 'true').toLowerCase() !== 'false';
11
+ const i18nDefaultLang = process.env.I18N_DEFAULT_LANG ?? 'en';
12
+ const i18nFallbackLang = process.env.I18N_FALLBACK_LANG ?? 'en';
13
+ const i18nPath = join(__dirname, '..', '..', '..', 'resources', 'i18n');
14
+
15
+ @Module({
16
+ imports: [
17
+ ConfigModule.forRoot({
18
+ isGlobal: true,
19
+ load: [appConfig],
20
+ envFilePath: '.env',
21
+ }),
22
+ ForgeonI18nModule.register({
23
+ enabled: i18nEnabled,
24
+ defaultLang: i18nDefaultLang,
25
+ fallbackLang: i18nFallbackLang,
26
+ path: i18nPath,
27
+ }),
28
+ PrismaModule,
29
+ ],
30
+ controllers: [HealthController],
31
+ providers: [AppExceptionFilter],
32
+ })
33
+ export class AppModule {}
34
+
@@ -0,0 +1,6 @@
1
+ import { IsNotEmpty } from 'class-validator';
2
+
3
+ export class EchoQueryDto {
4
+ @IsNotEmpty({ message: 'validation.required' })
5
+ value!: string;
6
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ ArgumentsHost,
3
+ Catch,
4
+ ExceptionFilter,
5
+ HttpException,
6
+ HttpStatus,
7
+ Injectable,
8
+ Optional,
9
+ } from '@nestjs/common';
10
+ import { I18nService } from 'nestjs-i18n';
11
+ import { Request, Response } from 'express';
12
+
13
+ @Injectable()
14
+ @Catch()
15
+ export class AppExceptionFilter implements ExceptionFilter {
16
+ constructor(@Optional() private readonly i18n?: I18nService) {}
17
+
18
+ catch(exception: unknown, host: ArgumentsHost): void {
19
+ const context = host.switchToHttp();
20
+ const response = context.getResponse<Response>();
21
+ const request = context.getRequest<Request>();
22
+
23
+ const status =
24
+ exception instanceof HttpException
25
+ ? exception.getStatus()
26
+ : HttpStatus.INTERNAL_SERVER_ERROR;
27
+
28
+ const payload =
29
+ exception instanceof HttpException
30
+ ? exception.getResponse()
31
+ : { message: 'Internal server error' };
32
+
33
+ const code = this.resolveCode(status);
34
+ const message = this.resolveMessage(payload, status, request);
35
+ const details = this.resolveDetails(payload);
36
+
37
+ response.status(status).json({
38
+ error: {
39
+ code,
40
+ message,
41
+ ...(details !== undefined ? { details } : {}),
42
+ },
43
+ });
44
+ }
45
+
46
+ private resolveCode(status: number): string {
47
+ switch (status) {
48
+ case HttpStatus.BAD_REQUEST:
49
+ return 'validation_error';
50
+ case HttpStatus.UNAUTHORIZED:
51
+ return 'unauthorized';
52
+ case HttpStatus.FORBIDDEN:
53
+ return 'forbidden';
54
+ case HttpStatus.NOT_FOUND:
55
+ return 'not_found';
56
+ case HttpStatus.CONFLICT:
57
+ return 'conflict';
58
+ default:
59
+ return 'internal_error';
60
+ }
61
+ }
62
+
63
+ private resolveMessage(
64
+ payload: unknown,
65
+ status: number,
66
+ request: Request,
67
+ ): string {
68
+ const lang = this.resolveLang(request);
69
+
70
+ if (status === HttpStatus.NOT_FOUND) {
71
+ return this.translate('errors.notFound', lang);
72
+ }
73
+
74
+ if (typeof payload === 'string') {
75
+ return this.translate(payload, lang);
76
+ }
77
+
78
+ if (typeof payload === 'object' && payload !== null) {
79
+ const obj = payload as { message?: string | string[] };
80
+ if (Array.isArray(obj.message) && obj.message.length > 0) {
81
+ return this.translate(obj.message[0], lang);
82
+ }
83
+ if (typeof obj.message === 'string') {
84
+ return this.translate(obj.message, lang);
85
+ }
86
+ }
87
+
88
+ return 'Internal server error';
89
+ }
90
+
91
+ private resolveDetails(payload: unknown): unknown {
92
+ if (typeof payload !== 'object' || payload === null) {
93
+ return undefined;
94
+ }
95
+
96
+ const obj = payload as { message?: string | string[]; error?: string };
97
+ if (Array.isArray(obj.message) && obj.message.length > 1) {
98
+ return obj.message;
99
+ }
100
+
101
+ return undefined;
102
+ }
103
+
104
+ private resolveLang(request: Request): string | undefined {
105
+ const queryLang = request.query?.lang;
106
+ if (typeof queryLang === 'string' && queryLang.length > 0) {
107
+ return queryLang;
108
+ }
109
+
110
+ const header = request.headers['accept-language'];
111
+ if (typeof header === 'string' && header.length > 0) {
112
+ return header.split(',')[0].trim();
113
+ }
114
+
115
+ return undefined;
116
+ }
117
+
118
+ private translate(keyOrMessage: string, lang?: string): string {
119
+ if (!this.i18n) {
120
+ return keyOrMessage;
121
+ }
122
+
123
+ const translated = this.i18n.t(keyOrMessage, {
124
+ lang,
125
+ defaultValue: keyOrMessage,
126
+ });
127
+
128
+ return typeof translated === 'string' ? translated : keyOrMessage;
129
+ }
130
+ }
@@ -0,0 +1,13 @@
1
+ import { registerAs } from '@nestjs/config';
2
+
3
+ const toBoolean = (value: string | undefined, defaultValue: boolean): boolean => {
4
+ if (value === undefined) return defaultValue;
5
+ return value.toLowerCase() === 'true';
6
+ };
7
+
8
+ export default registerAs('app', () => ({
9
+ port: Number(process.env.PORT ?? 3000),
10
+ i18nEnabled: toBoolean(process.env.I18N_ENABLED, true),
11
+ i18nDefaultLang: process.env.I18N_DEFAULT_LANG ?? 'en',
12
+ i18nFallbackLang: process.env.I18N_FALLBACK_LANG ?? 'en',
13
+ }));