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.
- package/README.md +11 -0
- package/bin/create-forgeon.mjs +265 -0
- package/package.json +16 -0
- package/templates/base/.editorconfig +11 -0
- package/templates/base/README.md +56 -0
- package/templates/base/apps/api/Dockerfile +23 -0
- package/templates/base/apps/api/package.json +40 -0
- package/templates/base/apps/api/prisma/migrations/0001_init/migration.sql +12 -0
- package/templates/base/apps/api/prisma/migrations/migration_lock.toml +1 -0
- package/templates/base/apps/api/prisma/schema.prisma +15 -0
- package/templates/base/apps/api/prisma/seed.ts +20 -0
- package/templates/base/apps/api/src/app.module.ts +34 -0
- package/templates/base/apps/api/src/common/dto/echo-query.dto.ts +6 -0
- package/templates/base/apps/api/src/common/filters/app-exception.filter.ts +130 -0
- package/templates/base/apps/api/src/config/app.config.ts +13 -0
- package/templates/base/apps/api/src/health/health.controller.ts +31 -0
- package/templates/base/apps/api/src/main.ts +26 -0
- package/templates/base/apps/api/src/prisma/prisma.module.ts +9 -0
- package/templates/base/apps/api/src/prisma/prisma.service.ts +27 -0
- package/templates/base/apps/api/tsconfig.build.json +9 -0
- package/templates/base/apps/api/tsconfig.json +9 -0
- package/templates/base/apps/web/Dockerfile +13 -0
- package/templates/base/apps/web/index.html +13 -0
- package/templates/base/apps/web/package.json +23 -0
- package/templates/base/apps/web/src/App.tsx +37 -0
- package/templates/base/apps/web/src/main.tsx +9 -0
- package/templates/base/apps/web/src/styles.css +33 -0
- package/templates/base/apps/web/tsconfig.json +18 -0
- package/templates/base/apps/web/vite.config.ts +15 -0
- package/templates/base/docs/AI/ARCHITECTURE.md +38 -0
- package/templates/base/docs/AI/PROJECT.md +32 -0
- package/templates/base/docs/AI/TASKS.md +48 -0
- package/templates/base/docs/README.md +5 -0
- package/templates/base/infra/docker/.env.example +10 -0
- package/templates/base/infra/docker/compose.yml +45 -0
- package/templates/base/infra/docker/nginx.Dockerfile +16 -0
- package/templates/base/infra/nginx/nginx.conf +32 -0
- package/templates/base/package.json +24 -0
- package/templates/base/packages/core/README.md +4 -0
- package/templates/base/packages/core/package.json +14 -0
- package/templates/base/packages/core/src/index.ts +1 -0
- package/templates/base/packages/core/tsconfig.json +8 -0
- package/templates/base/packages/i18n/package.json +19 -0
- package/templates/base/packages/i18n/src/forgeon-i18n.module.ts +47 -0
- package/templates/base/packages/i18n/src/index.ts +2 -0
- package/templates/base/packages/i18n/tsconfig.json +9 -0
- package/templates/base/pnpm-workspace.yaml +3 -0
- package/templates/base/resources/i18n/en/common.json +5 -0
- package/templates/base/resources/i18n/en/validation.json +3 -0
- package/templates/base/resources/i18n/uk/common.json +5 -0
- package/templates/base/resources/i18n/uk/validation.json +3 -0
- 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,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,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
|
+
}));
|