base-lw-project-test 1.0.0

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 (57) hide show
  1. package/bin/index.js +139 -0
  2. package/package.json +27 -0
  3. package/template/.env +1 -0
  4. package/template/ecosystem.config.js +18 -0
  5. package/template/package.json +39 -0
  6. package/template/scripts/commit-smart.sh +29 -0
  7. package/template/src/config/cors.ts +24 -0
  8. package/template/src/config/database.ts +15 -0
  9. package/template/src/config/env.ts +54 -0
  10. package/template/src/config/index.ts +4 -0
  11. package/template/src/config/logger.ts +11 -0
  12. package/template/src/modules/example/example.controller.ts +62 -0
  13. package/template/src/modules/example/example.repository.ts +48 -0
  14. package/template/src/modules/example/example.routes.ts +15 -0
  15. package/template/src/modules/example/example.schema.ts +30 -0
  16. package/template/src/modules/example/example.service.ts +139 -0
  17. package/template/src/modules/example/example.validators.ts +19 -0
  18. package/template/src/modules/example/index.ts +6 -0
  19. package/template/src/modules/health/health.controller.ts +10 -0
  20. package/template/src/modules/health/health.routes.ts +6 -0
  21. package/template/src/modules/health/index.ts +2 -0
  22. package/template/src/routes/index.ts +8 -0
  23. package/template/src/server.ts +41 -0
  24. package/template/src/shared/constants/db.constant.ts +3 -0
  25. package/template/src/shared/constants/errors.constant.ts +8 -0
  26. package/template/src/shared/constants/index.ts +2 -0
  27. package/template/src/shared/errors/AlreadyExistsException.ts +14 -0
  28. package/template/src/shared/errors/BadRequestException.ts +11 -0
  29. package/template/src/shared/errors/BaseError.ts +12 -0
  30. package/template/src/shared/errors/ErrorResponse.ts +14 -0
  31. package/template/src/shared/errors/ForbiddenException.ts +12 -0
  32. package/template/src/shared/errors/InternalServerException.ts +11 -0
  33. package/template/src/shared/errors/InvalidParamsException.ts +12 -0
  34. package/template/src/shared/errors/NotFoundException.ts +12 -0
  35. package/template/src/shared/errors/UnauthorizedException.ts +12 -0
  36. package/template/src/shared/errors/error.handler.ts +23 -0
  37. package/template/src/shared/errors/index.ts +10 -0
  38. package/template/src/shared/handlers/health.handler.ts +9 -0
  39. package/template/src/shared/handlers/index.ts +1 -0
  40. package/template/src/shared/helpers/index.ts +1 -0
  41. package/template/src/shared/helpers/object.helper.ts +5 -0
  42. package/template/src/shared/middlewares/auth.middleware.ts +32 -0
  43. package/template/src/shared/middlewares/index.ts +1 -0
  44. package/template/src/shared/types/auth.context.ts +4 -0
  45. package/template/src/shared/types/fastify.d.ts +8 -0
  46. package/template/src/shared/types/index.ts +3 -0
  47. package/template/src/shared/types/mongo.ts +12 -0
  48. package/template/src/shared/types/route.ts +35 -0
  49. package/template/src/shared/utils/index.ts +2 -0
  50. package/template/src/shared/utils/jwt.ts +26 -0
  51. package/template/src/shared/utils/validate.ts +11 -0
  52. package/template/src/shared/validators/index.ts +3 -0
  53. package/template/src/shared/validators/number.utils.ts +15 -0
  54. package/template/src/shared/validators/objectId.ts +6 -0
  55. package/template/src/shared/validators/pageable.schema.ts +11 -0
  56. package/template/src/shared/validators/yup.ts +9 -0
  57. package/template/tsconfig.json +41 -0
package/bin/index.js ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "fs-extra";
4
+ import path from "path";
5
+ import inquirer from "inquirer";
6
+ import chalk from "chalk";
7
+ import ora from "ora";
8
+ import { execSync } from "child_process";
9
+ import { fileURLToPath } from "url";
10
+
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = path.dirname(__filename);
13
+
14
+ // 🔥 pega autor do git
15
+ const getGitUser = () => {
16
+ try {
17
+ return execSync("git config user.name").toString().trim();
18
+ } catch {
19
+ return "";
20
+ }
21
+ };
22
+
23
+ // 🔁 replace global
24
+ const replaceInFiles = async (dir, replacements) => {
25
+ const files = await fs.readdir(dir);
26
+
27
+ for (const file of files) {
28
+ const fullPath = path.join(dir, file);
29
+ const stat = await fs.stat(fullPath);
30
+
31
+ if (stat.isDirectory()) {
32
+ await replaceInFiles(fullPath, replacements);
33
+ } else {
34
+ let content = await fs.readFile(fullPath, "utf8");
35
+
36
+ for (const [key, value] of Object.entries(replacements)) {
37
+ const regex = new RegExp(key, "g");
38
+ content = content.replace(regex, value);
39
+ }
40
+
41
+ await fs.writeFile(fullPath, content);
42
+ }
43
+ }
44
+ };
45
+
46
+ const run = async () => {
47
+ console.log(chalk.green("🚀 Criando projeto base LW...\n"));
48
+
49
+ const answers = await inquirer.prompt([
50
+ {
51
+ type: "input",
52
+ name: "projectName",
53
+ message: "Nome do projeto:",
54
+ default: "my-app",
55
+ },
56
+ {
57
+ type: "input",
58
+ name: "port",
59
+ message: "Porta da aplicação:",
60
+ default: "3000",
61
+ },
62
+ {
63
+ type: "input",
64
+ name: "description",
65
+ message: "Descrição:",
66
+ default: "API com Fastify + Mongoose",
67
+ },
68
+ {
69
+ type: "input",
70
+ name: "author",
71
+ message: "Autor:",
72
+ default: getGitUser(),
73
+ },
74
+ {
75
+ type: "list",
76
+ name: "packageManager",
77
+ message: "Gerenciador de pacotes:",
78
+ choices: ["npm", "yarn", "pnpm"],
79
+ default: "npm",
80
+ },
81
+ ]);
82
+
83
+ const projectPath = path.resolve(process.cwd(), answers.projectName);
84
+ const templatePath = path.join(__dirname, "../template");
85
+
86
+ // ❌ evita sobrescrever
87
+ if (fs.existsSync(projectPath)) {
88
+ console.log(chalk.red("❌ Essa pasta já existe"));
89
+ process.exit(1);
90
+ }
91
+
92
+ // 🚀 copiar template
93
+ const copySpinner = ora("Copiando template...").start();
94
+ await fs.copy(templatePath, projectPath);
95
+ copySpinner.succeed("Template copiado!");
96
+
97
+ // 🔁 replace global
98
+ const replaceSpinner = ora("Configurando projeto...").start();
99
+
100
+ await replaceInFiles(projectPath, {
101
+ "__PROJECT_NAME__": answers.projectName,
102
+ "__PORT__": answers.port,
103
+ "__DESCRIPTION__": answers.description,
104
+ "__AUTHOR__": answers.author || "Unknown",
105
+ });
106
+
107
+ replaceSpinner.succeed("Projeto configurado!");
108
+
109
+ // 📦 instalar deps baseado no gerenciador
110
+ const installSpinner = ora("Instalando dependências...").start();
111
+
112
+ let installCommand = "";
113
+ let runDevCommand = "";
114
+
115
+ if (answers.packageManager === "yarn") {
116
+ installCommand = "yarn";
117
+ runDevCommand = "yarn dev";
118
+ } else if (answers.packageManager === "pnpm") {
119
+ installCommand = "pnpm install";
120
+ runDevCommand = "pnpm dev";
121
+ } else {
122
+ installCommand = "npm install";
123
+ runDevCommand = "npm run dev";
124
+ }
125
+
126
+ execSync(installCommand, {
127
+ cwd: projectPath,
128
+ stdio: "ignore",
129
+ });
130
+
131
+ installSpinner.succeed("Dependências instaladas!");
132
+
133
+ console.log(chalk.green("\n✅ Projeto criado com sucesso!"));
134
+ console.log(chalk.yellow("\n👉 Próximos passos:"));
135
+ console.log(chalk.blue(`cd ${answers.projectName}`));
136
+ console.log(chalk.blue(runDevCommand + "\n"));
137
+ };
138
+
139
+ run();
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "base-lw-project-test",
3
+ "version": "1.0.0",
4
+ "description": "CLI para gerar projetos Fastify + Mongoose",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "base-lw-project": "./bin/index.js"
8
+ },
9
+ "type": "module",
10
+ "dependencies": {
11
+ "chalk": "^5.0.0",
12
+ "fs-extra": "^11.2.0",
13
+ "inquirer": "^9.0.0",
14
+ "ora": "^9.3.0"
15
+ },
16
+ "files": [
17
+ "bin",
18
+ "template"
19
+ ],
20
+ "keywords": [
21
+ "cli",
22
+ "fastify",
23
+ "mongoose",
24
+ "template",
25
+ "scaffold"
26
+ ]
27
+ }
package/template/.env ADDED
@@ -0,0 +1 @@
1
+ PORT=__PORT__
@@ -0,0 +1,18 @@
1
+ module.exports = {
2
+ apps: [
3
+ {
4
+ name: 'base-project-api',
5
+ script: './dist/server.js',
6
+ instances: 1,
7
+ autorestart: true,
8
+ watch: false,
9
+ max_memory_restart: '1G',
10
+ env: {
11
+ NODE_ENV: 'development',
12
+ },
13
+ env_production: {
14
+ NODE_ENV: 'production',
15
+ },
16
+ },
17
+ ],
18
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "1.0.0",
4
+ "main": "index.js",
5
+ "license": "MIT",
6
+ "author": "__AUTHOR__",
7
+ "scripts": {
8
+ "dev": "tsx watch src/server.ts",
9
+ "typecheck": "tsc --noEmit",
10
+ "lint": "eslint . --ext .ts",
11
+ "lint:fix": "eslint . --ext .ts --fix",
12
+ "format": "prettier . --write",
13
+ "format:check": "prettier . --check",
14
+ "commit:smart": "bash scripts/commit-smart.sh",
15
+ "build": "tsup src",
16
+ "start": "node ./dist/server.js"
17
+ },
18
+ "dependencies": {
19
+ "@fastify/cors": "11.2.0",
20
+ "axios": "^1.14.0",
21
+ "dotenv": "^17.3.1",
22
+ "fastify": "^5.8.4",
23
+ "jsonwebtoken": "^9.0.3",
24
+ "mongoose": "^9.3.3",
25
+ "tsup": "8.5.1",
26
+ "yup": "^1.7.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/jsonwebtoken": "^9.0.10",
30
+ "@types/node": "^25.5.0",
31
+ "@typescript-eslint/eslint-plugin": "^8.57.2",
32
+ "@typescript-eslint/parser": "^8.57.2",
33
+ "eslint": "^8",
34
+ "eslint-config-prettier": "^10.1.8",
35
+ "prettier": "^3.8.1",
36
+ "tsx": "^4.21.0",
37
+ "typescript": "^5.9.3"
38
+ }
39
+ }
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ echo "-> Formatando o projeto..."
5
+ yarn format
6
+
7
+ echo "-> Validando lint..."
8
+ yarn lint
9
+
10
+ echo
11
+ read -r -p "Mensagem do commit: " commit_message
12
+
13
+ if [[ -z "${commit_message}" ]]; then
14
+ echo "Mensagem de commit vazia. Operacao cancelada."
15
+ exit 1
16
+ fi
17
+
18
+ echo "-> Adicionando arquivos..."
19
+ git add -A
20
+
21
+ if git diff --cached --quiet; then
22
+ echo "Nenhuma alteracao para commitar."
23
+ exit 1
24
+ fi
25
+
26
+ echo "-> Criando commit..."
27
+ git commit -m "${commit_message}"
28
+
29
+ echo "Commit criado com sucesso."
@@ -0,0 +1,24 @@
1
+ import { FastifyCorsOptions, FastifyCorsOptionsDelegate } from '@fastify/cors'
2
+ import { FastifyRegisterOptions } from 'fastify'
3
+ import { env } from './env'
4
+
5
+ const allowedOrigins = env.CORS_ORIGINS.split(',')
6
+ .map((origin) => origin.trim())
7
+ .filter(Boolean)
8
+
9
+ export const corsConfig: FastifyRegisterOptions<FastifyCorsOptions | FastifyCorsOptionsDelegate> = {
10
+ origin: (origin, cb) => {
11
+ if (!origin) {
12
+ cb(null, true)
13
+ return
14
+ }
15
+
16
+ if (allowedOrigins.includes(origin)) {
17
+ cb(null, true)
18
+ } else {
19
+ cb(new Error(`Not allowed by CORS: ${origin}`), false)
20
+ }
21
+ },
22
+
23
+ methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
24
+ }
@@ -0,0 +1,15 @@
1
+ import mongoose from 'mongoose'
2
+ import { env } from '@config/env'
3
+
4
+ const MONGO_URI = `${env.DB_URI}/${env.DB_NAME}`
5
+
6
+ export const connectDatabase = async () => {
7
+ try {
8
+ await mongoose.connect(MONGO_URI)
9
+
10
+ console.log('✅ Mongo connected')
11
+ } catch (error) {
12
+ console.error('❌ Mongo connection error', error)
13
+ process.exit(1)
14
+ }
15
+ }
@@ -0,0 +1,54 @@
1
+ import * as yup from 'yup'
2
+ import 'dotenv/config'
3
+
4
+ const envSchema = yup.object({
5
+ NODE_ENV: yup
6
+ .mixed<'development' | 'test' | 'production'>()
7
+ .oneOf(['development', 'test', 'production'])
8
+ .default('development')
9
+ .required(),
10
+
11
+ PORT: yup
12
+ .number()
13
+ .transform((_, originalValue) => Number(originalValue))
14
+ .default(8080)
15
+ .required(),
16
+
17
+ BODY_LIMIT: yup
18
+ .number()
19
+ .transform((_, originalValue) => Number(originalValue))
20
+ .default(10485760)
21
+ .required(),
22
+
23
+ JWT_EXPIRES_IN: yup.string().default('7d').required(),
24
+
25
+ JWT_SECRET: yup.string().min(10).required(),
26
+
27
+ DB_NAME: yup.string().min(1).required(),
28
+
29
+ DB_URI: yup
30
+ .string()
31
+ .matches(/^mongodb(\+srv)?:\/\//, 'DB must be a valid Mongo URI')
32
+ .required(),
33
+
34
+ CORS_ORIGINS: yup.string().default('http://localhost:3000').required(),
35
+ })
36
+
37
+ let env: yup.InferType<typeof envSchema>
38
+
39
+ try {
40
+ env = envSchema.validateSync(process.env, {
41
+ abortEarly: false,
42
+ stripUnknown: true,
43
+ })
44
+ } catch (err) {
45
+ console.error('❌ Invalid environment variables')
46
+
47
+ if (err instanceof yup.ValidationError) {
48
+ console.error(err.errors)
49
+ }
50
+
51
+ process.exit(1)
52
+ }
53
+
54
+ export { env }
@@ -0,0 +1,4 @@
1
+ export * from './cors'
2
+ export * from './database'
3
+ export * from './env'
4
+ export * from './logger'
@@ -0,0 +1,11 @@
1
+ import { FastifyServerOptions } from 'fastify'
2
+ import { env } from './env'
3
+
4
+ export const loggerConfig: FastifyServerOptions['logger'] =
5
+ env.NODE_ENV === 'production'
6
+ ? {
7
+ level: 'info',
8
+ }
9
+ : {
10
+ level: 'debug',
11
+ }
@@ -0,0 +1,62 @@
1
+ import { AppRouteHandler, RHWithParamsId } from '@types'
2
+ import {
3
+ createExampleSchema,
4
+ listExamplesSchema,
5
+ paramsIdSchema,
6
+ updateExampleSchema,
7
+ } from './example.validators'
8
+ import { ExampleService } from './example.service'
9
+ import { validateSchema } from '@utils'
10
+ import { HttpStatusCode } from 'axios'
11
+
12
+ const createExample: AppRouteHandler = async (req, res) => {
13
+ const body = await validateSchema(createExampleSchema, req.body)
14
+
15
+ const result = await ExampleService.createExample(body)
16
+
17
+ return res.status(HttpStatusCode.Created).send(result)
18
+ }
19
+
20
+ const listExamples: AppRouteHandler = async (req, res) => {
21
+ const query = await validateSchema(listExamplesSchema, req.query)
22
+
23
+ const result = await ExampleService.listExamples(query)
24
+
25
+ return res.status(HttpStatusCode.Ok).send(result)
26
+ }
27
+
28
+ const getExampleById: RHWithParamsId = async (req, res) => {
29
+ const params = await validateSchema(paramsIdSchema, req.params)
30
+
31
+ const result = await ExampleService.getExampleById(params.id)
32
+
33
+ return res.status(HttpStatusCode.Ok).send(result)
34
+ }
35
+
36
+ const updateExample: RHWithParamsId = async (req, res) => {
37
+ const params = await validateSchema(paramsIdSchema, req.params)
38
+ const body = await validateSchema(updateExampleSchema, req.body)
39
+
40
+ const result = await ExampleService.updateExample({
41
+ id: params.id,
42
+ ...body,
43
+ })
44
+
45
+ return res.status(HttpStatusCode.Ok).send(result)
46
+ }
47
+
48
+ const deleteExample: RHWithParamsId = async (req, res) => {
49
+ const params = await validateSchema(paramsIdSchema, req.params)
50
+
51
+ await ExampleService.deleteExample(params.id)
52
+
53
+ return res.status(HttpStatusCode.NoContent).send()
54
+ }
55
+
56
+ export const ExampleController = {
57
+ createExample,
58
+ listExamples,
59
+ getExampleById,
60
+ updateExample,
61
+ deleteExample,
62
+ }
@@ -0,0 +1,48 @@
1
+ import { ExampleModel } from './example.schema'
2
+
3
+ const findByName = async (name: string) => {
4
+ return ExampleModel.findOne({ name })
5
+ }
6
+
7
+ const findById = async (id: string) => {
8
+ return ExampleModel.findById(id)
9
+ }
10
+
11
+ const list = async ({ page, limit }: { page: number; limit: number }) => {
12
+ const skip = (page - 1) * limit
13
+
14
+ const [items, total] = await Promise.all([
15
+ ExampleModel.find().sort({ createdAt: -1 }).skip(skip).limit(limit),
16
+ ExampleModel.countDocuments(),
17
+ ])
18
+
19
+ return { items, total }
20
+ }
21
+
22
+ const create = async (data: { name: string; description?: string }) => {
23
+ return ExampleModel.create(data)
24
+ }
25
+
26
+ const update = async (
27
+ id: string,
28
+ data: {
29
+ name?: string
30
+ description?: string
31
+ isActive?: boolean
32
+ },
33
+ ) => {
34
+ return ExampleModel.findByIdAndUpdate(id, data, { new: true })
35
+ }
36
+
37
+ const remove = async (id: string) => {
38
+ return ExampleModel.findByIdAndDelete(id)
39
+ }
40
+
41
+ export const ExampleRepository = {
42
+ findByName,
43
+ findById,
44
+ list,
45
+ create,
46
+ update,
47
+ remove,
48
+ }
@@ -0,0 +1,15 @@
1
+ import { FastifyPluginAsync } from 'fastify'
2
+ import { AuthMiddleware } from '@middlewares'
3
+ import { ExampleController } from './example.controller'
4
+
5
+ export const exampleRoutes: FastifyPluginAsync = async (fastify) => {
6
+ fastify.register(async (authenticated) => {
7
+ authenticated.addHook('preHandler', AuthMiddleware)
8
+
9
+ authenticated.post('/', ExampleController.createExample)
10
+ authenticated.get('/', ExampleController.listExamples)
11
+ authenticated.get('/:id', ExampleController.getExampleById)
12
+ authenticated.patch('/:id', ExampleController.updateExample)
13
+ authenticated.delete('/:id', ExampleController.deleteExample)
14
+ })
15
+ }
@@ -0,0 +1,30 @@
1
+ import { Schema, model, InferSchemaType } from 'mongoose'
2
+ import { COLLECTIONS } from '@constants'
3
+
4
+ const ExampleSchema = new Schema(
5
+ {
6
+ name: {
7
+ type: String,
8
+ required: true,
9
+ unique: true,
10
+ trim: true,
11
+ },
12
+ description: {
13
+ type: String,
14
+ required: false,
15
+ trim: true,
16
+ },
17
+ isActive: {
18
+ type: Boolean,
19
+ default: true,
20
+ },
21
+ },
22
+ {
23
+ timestamps: true,
24
+ versionKey: false,
25
+ },
26
+ )
27
+
28
+ export type Example = InferSchemaType<typeof ExampleSchema>
29
+
30
+ export const ExampleModel = model('Example', ExampleSchema, COLLECTIONS.EXAMPLES)
@@ -0,0 +1,139 @@
1
+ import {
2
+ AlreadyExistsException,
3
+ BadRequestException,
4
+ NotFoundException,
5
+ } from '@errors'
6
+ import { omitUndefined } from '@helpers'
7
+ import { ExampleRepository } from './example.repository'
8
+
9
+ type CreateExampleInput = {
10
+ name: string
11
+ description?: string
12
+ }
13
+
14
+ type UpdateExampleInput = {
15
+ id: string
16
+ name?: string
17
+ description?: string
18
+ isActive?: boolean
19
+ }
20
+
21
+ type ListExamplesInput = {
22
+ page?: number
23
+ limit?: number
24
+ }
25
+
26
+ type ExampleResponse = {
27
+ id: string
28
+ name: string
29
+ description?: string
30
+ isActive: boolean
31
+ createdAt: Date
32
+ updatedAt: Date
33
+ }
34
+
35
+ const toExampleResponse = (example: {
36
+ _id: { toString(): string }
37
+ name: string
38
+ description?: string | null
39
+ isActive: boolean
40
+ createdAt: Date
41
+ updatedAt: Date
42
+ }): ExampleResponse => {
43
+ return {
44
+ id: example._id.toString(),
45
+ name: example.name,
46
+ description: example.description ?? undefined,
47
+ isActive: example.isActive,
48
+ createdAt: example.createdAt,
49
+ updatedAt: example.updatedAt,
50
+ }
51
+ }
52
+
53
+ const createExample = async ({ name, description }: CreateExampleInput) => {
54
+ const exists = await ExampleRepository.findByName(name)
55
+
56
+ if (exists) {
57
+ throw new AlreadyExistsException('Example with this name already exists')
58
+ }
59
+
60
+ const example = await ExampleRepository.create({
61
+ name,
62
+ description,
63
+ })
64
+
65
+ return {
66
+ data: toExampleResponse(example),
67
+ }
68
+ }
69
+
70
+ const listExamples = async ({ page = 1, limit = 10 }: ListExamplesInput) => {
71
+ const result = await ExampleRepository.list({
72
+ page,
73
+ limit,
74
+ })
75
+
76
+ return {
77
+ data: result.items.map(toExampleResponse),
78
+ meta: {
79
+ page,
80
+ limit,
81
+ total: result.total,
82
+ totalPages: Math.ceil(result.total / limit),
83
+ },
84
+ }
85
+ }
86
+
87
+ const getExampleById = async (id: string) => {
88
+ const example = await ExampleRepository.findById(id)
89
+
90
+ if (!example) {
91
+ throw new NotFoundException('Example not found')
92
+ }
93
+
94
+ return {
95
+ data: toExampleResponse(example),
96
+ }
97
+ }
98
+
99
+ const updateExample = async ({ id, name, description, isActive }: UpdateExampleInput) => {
100
+ const payload = omitUndefined({ name, description, isActive })
101
+
102
+ if (Object.keys(payload).length === 0) {
103
+ throw new BadRequestException('No fields provided for update')
104
+ }
105
+
106
+ if (name) {
107
+ const exists = await ExampleRepository.findByName(name)
108
+
109
+ if (exists && exists._id.toString() !== id) {
110
+ throw new AlreadyExistsException('Example with this name already exists')
111
+ }
112
+ }
113
+
114
+ const updated = await ExampleRepository.update(id, payload)
115
+
116
+ if (!updated) {
117
+ throw new NotFoundException('Example not found')
118
+ }
119
+
120
+ return {
121
+ data: toExampleResponse(updated),
122
+ }
123
+ }
124
+
125
+ const deleteExample = async (id: string) => {
126
+ const deleted = await ExampleRepository.remove(id)
127
+
128
+ if (!deleted) {
129
+ throw new NotFoundException('Example not found')
130
+ }
131
+ }
132
+
133
+ export const ExampleService = {
134
+ createExample,
135
+ listExamples,
136
+ getExampleById,
137
+ updateExample,
138
+ deleteExample,
139
+ }
@@ -0,0 +1,19 @@
1
+ import { pageableSchema } from '@validators'
2
+ import { boolean, object, string } from 'yup'
3
+
4
+ export const createExampleSchema = object({
5
+ name: string().min(2).required(),
6
+ description: string().max(1000).optional(),
7
+ })
8
+
9
+ export const listExamplesSchema = pageableSchema
10
+
11
+ export const paramsIdSchema = object({
12
+ id: string().required(),
13
+ })
14
+
15
+ export const updateExampleSchema = object({
16
+ name: string().min(2).optional(),
17
+ description: string().max(1000).optional(),
18
+ isActive: boolean().optional(),
19
+ })
@@ -0,0 +1,6 @@
1
+ export * from './example.schema'
2
+ export * from './example.repository'
3
+ export * from './example.validators'
4
+ export * from './example.service'
5
+ export * from './example.controller'
6
+ export * from './example.routes'
@@ -0,0 +1,10 @@
1
+ import { healthHandler } from '@handlers'
2
+ import { AppRouteHandler } from '@types'
3
+
4
+ const check: AppRouteHandler = async (req, res) => {
5
+ return healthHandler(req, res)
6
+ }
7
+
8
+ export const HealthController = {
9
+ check,
10
+ }
@@ -0,0 +1,6 @@
1
+ import { FastifyPluginAsync } from 'fastify'
2
+ import { HealthController } from './health.controller'
3
+
4
+ export const healthRoutes: FastifyPluginAsync = async (fastify) => {
5
+ fastify.get('/', HealthController.check)
6
+ }
@@ -0,0 +1,2 @@
1
+ export * from './health.controller'
2
+ export * from './health.routes'
@@ -0,0 +1,8 @@
1
+ import { exampleRoutes } from '../modules/example/example.routes'
2
+ import { healthRoutes } from '../modules/health/health.routes'
3
+ import { FastifyInstance } from 'fastify'
4
+
5
+ export const registerRoutes = async (app: FastifyInstance) => {
6
+ app.register(healthRoutes, { prefix: '/health' })
7
+ app.register(exampleRoutes, { prefix: '/examples' })
8
+ }
@@ -0,0 +1,41 @@
1
+ import Fastify from "fastify";
2
+ import { connectDatabase, corsConfig, env, loggerConfig } from "@config";
3
+ import { errorHandler } from "@errors";
4
+ import { registerRoutes } from "@routes";
5
+ import cors from "@fastify/cors";
6
+ import "@validators/yup";
7
+
8
+ export const buildApp = () => {
9
+ const app = Fastify({
10
+ logger: loggerConfig,
11
+ bodyLimit: env.BODY_LIMIT,
12
+ });
13
+
14
+ app.setErrorHandler(errorHandler);
15
+
16
+ app.register(cors, corsConfig);
17
+
18
+ app.register(registerRoutes);
19
+
20
+ return app;
21
+ };
22
+
23
+ const start = async () => {
24
+ const app = buildApp();
25
+
26
+ try {
27
+ await connectDatabase();
28
+
29
+ await app.listen({
30
+ port: env.PORT,
31
+ host: "0.0.0.0",
32
+ });
33
+
34
+ app.log.info(`Server __PROJECT_NAME__ listening on port ${env.PORT}`);
35
+ } catch (err) {
36
+ app.log.error(err);
37
+ process.exit(1);
38
+ }
39
+ };
40
+
41
+ void start();
@@ -0,0 +1,3 @@
1
+ export const COLLECTIONS = {
2
+ EXAMPLES: 'examples',
3
+ }
@@ -0,0 +1,8 @@
1
+ export const ERRORS_MESSAGES = {
2
+ UNEXPECTED_ERROR: 'An unexpected error occurred.',
3
+ ALREADY_EXISTS: 'Resource already exists.',
4
+ INVALID_PARAMS: 'Invalid request payload.',
5
+ NOT_FOUND: 'Resource not found.',
6
+ UNAUTHORIZED: 'Unauthorized.',
7
+ FORBIDDEN: 'Forbidden.',
8
+ }
@@ -0,0 +1,2 @@
1
+ export * from './db.constant'
2
+ export * from './errors.constant'
@@ -0,0 +1,14 @@
1
+ import { HttpStatusCode } from 'axios'
2
+ import { ERRORS_MESSAGES } from '@constants'
3
+ import { BaseError } from '@errors/BaseError'
4
+
5
+ const DEFAULT_MESSAGE = ERRORS_MESSAGES.ALREADY_EXISTS
6
+
7
+ export class AlreadyExistsException extends BaseError {
8
+ constructor(message?: string) {
9
+ super({
10
+ message: message || DEFAULT_MESSAGE,
11
+ statusCode: HttpStatusCode.BadRequest,
12
+ })
13
+ }
14
+ }
@@ -0,0 +1,11 @@
1
+ import { HttpStatusCode } from 'axios'
2
+ import { BaseError } from '@errors/BaseError'
3
+
4
+ export class BadRequestException extends BaseError {
5
+ constructor(message?: string) {
6
+ super({
7
+ message: message,
8
+ statusCode: HttpStatusCode.BadRequest,
9
+ })
10
+ }
11
+ }
@@ -0,0 +1,12 @@
1
+ import { HttpStatusCode } from 'axios'
2
+ import { ERRORS_MESSAGES } from '@constants'
3
+
4
+ export class BaseError {
5
+ message: string
6
+ statusCode: number
7
+
8
+ constructor({ message, statusCode }: { message?: string; statusCode?: number }) {
9
+ this.message = message || ERRORS_MESSAGES.UNEXPECTED_ERROR
10
+ this.statusCode = statusCode || HttpStatusCode.InternalServerError
11
+ }
12
+ }
@@ -0,0 +1,14 @@
1
+ export enum CodesStatus {
2
+ Error = 1,
3
+ SystemOut,
4
+ }
5
+
6
+ export class ErrorResponse {
7
+ message: string
8
+ code = 1
9
+
10
+ constructor(message: string, code: number = CodesStatus.Error) {
11
+ this.message = message
12
+ this.code = code
13
+ }
14
+ }
@@ -0,0 +1,12 @@
1
+ import { HttpStatusCode } from 'axios'
2
+ import { ERRORS_MESSAGES } from '@constants'
3
+ import { BaseError } from '@errors/BaseError'
4
+
5
+ export class ForbiddenException extends BaseError {
6
+ constructor(message?: string) {
7
+ super({
8
+ message: message || ERRORS_MESSAGES.FORBIDDEN,
9
+ statusCode: HttpStatusCode.Forbidden,
10
+ })
11
+ }
12
+ }
@@ -0,0 +1,11 @@
1
+ import { HttpStatusCode } from 'axios'
2
+ import { BaseError } from '@errors/BaseError'
3
+
4
+ export class InternalServerException extends BaseError {
5
+ constructor(message?: string) {
6
+ super({
7
+ message: message,
8
+ statusCode: HttpStatusCode.InternalServerError,
9
+ })
10
+ }
11
+ }
@@ -0,0 +1,12 @@
1
+ import { HttpStatusCode } from 'axios'
2
+ import { ERRORS_MESSAGES } from '@constants'
3
+ import { BaseError } from '@errors/BaseError'
4
+
5
+ export class InvalidParamsException extends BaseError {
6
+ constructor(message?: string) {
7
+ super({
8
+ message: message || ERRORS_MESSAGES.INVALID_PARAMS,
9
+ statusCode: HttpStatusCode.BadRequest,
10
+ })
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ import { HttpStatusCode } from 'axios'
2
+ import { ERRORS_MESSAGES } from '@constants'
3
+ import { BaseError } from '@errors/BaseError'
4
+
5
+ export class NotFoundException extends BaseError {
6
+ constructor(message?: string) {
7
+ super({
8
+ message: message || ERRORS_MESSAGES.NOT_FOUND,
9
+ statusCode: HttpStatusCode.NotFound,
10
+ })
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ import { HttpStatusCode } from 'axios'
2
+ import { ERRORS_MESSAGES } from '@constants'
3
+ import { BaseError } from '@errors/BaseError'
4
+
5
+ export class UnauthorizedException extends BaseError {
6
+ constructor(message?: string) {
7
+ super({
8
+ message: message || ERRORS_MESSAGES.UNAUTHORIZED,
9
+ statusCode: HttpStatusCode.Unauthorized,
10
+ })
11
+ }
12
+ }
@@ -0,0 +1,23 @@
1
+ import { FastifyReply, FastifyRequest } from 'fastify'
2
+ import { BaseError, ErrorResponse } from '@errors'
3
+ import { ValidationError } from 'yup'
4
+ import { ERRORS_MESSAGES } from '@constants'
5
+ import { HttpStatusCode } from 'axios'
6
+
7
+ export const errorHandler = (error: unknown, _req: FastifyRequest, res: FastifyReply) => {
8
+ console.error(error)
9
+
10
+ if (error instanceof BaseError) {
11
+ return res.status(error.statusCode).send(new ErrorResponse(error.message))
12
+ }
13
+
14
+ if (error instanceof ValidationError) {
15
+ return res
16
+ .status(HttpStatusCode.BadRequest)
17
+ .send(new ErrorResponse(ERRORS_MESSAGES.INVALID_PARAMS))
18
+ }
19
+
20
+ return res
21
+ .status(HttpStatusCode.InternalServerError)
22
+ .send(new ErrorResponse(ERRORS_MESSAGES.UNEXPECTED_ERROR))
23
+ }
@@ -0,0 +1,10 @@
1
+ export * from './AlreadyExistsException'
2
+ export * from './BadRequestException'
3
+ export * from './BaseError'
4
+ export * from './ErrorResponse'
5
+ export * from './InternalServerException'
6
+ export * from './InvalidParamsException'
7
+ export * from './NotFoundException'
8
+ export * from './UnauthorizedException'
9
+ export * from './error.handler'
10
+ export * from './ForbiddenException'
@@ -0,0 +1,9 @@
1
+ import { HttpStatusCode } from 'axios'
2
+ import { FastifyReply, FastifyRequest } from 'fastify'
3
+
4
+ export const healthHandler = async (_req: FastifyRequest, reply: FastifyReply) => {
5
+ return reply.status(HttpStatusCode.Ok).send({
6
+ status: 'ok',
7
+ timestamp: new Date().toISOString(),
8
+ })
9
+ }
@@ -0,0 +1 @@
1
+ export * from './health.handler'
@@ -0,0 +1 @@
1
+ export * from './object.helper'
@@ -0,0 +1,5 @@
1
+ export const omitUndefined = <T extends Record<string, unknown>>(value: T): Partial<T> => {
2
+ return Object.fromEntries(
3
+ Object.entries(value).filter(([, fieldValue]) => fieldValue !== undefined),
4
+ ) as Partial<T>
5
+ }
@@ -0,0 +1,32 @@
1
+ import { UnauthorizedException } from '@errors'
2
+ import { verifyToken } from '@utils'
3
+ import { FastifyReply, FastifyRequest } from 'fastify'
4
+
5
+ export const AuthMiddleware = async (req: FastifyRequest, _res: FastifyReply) => {
6
+ const authHeader = req.headers.authorization
7
+
8
+ if (!authHeader) {
9
+ throw new UnauthorizedException('Missing authorization header')
10
+ }
11
+
12
+ const [scheme, token] = authHeader.split(' ')
13
+
14
+ if (scheme !== 'Bearer' || !token) {
15
+ throw new UnauthorizedException('Invalid token format')
16
+ }
17
+
18
+ try {
19
+ const { userId, role } = verifyToken(token)
20
+
21
+ if (!userId) {
22
+ throw new UnauthorizedException('Invalid token payload')
23
+ }
24
+
25
+ req.user = {
26
+ id: userId,
27
+ role,
28
+ }
29
+ } catch {
30
+ throw new UnauthorizedException('Invalid or expired token')
31
+ }
32
+ }
@@ -0,0 +1 @@
1
+ export * from './auth.middleware'
@@ -0,0 +1,4 @@
1
+ export interface AuthContext {
2
+ id: string
3
+ role?: string
4
+ }
@@ -0,0 +1,8 @@
1
+ import 'fastify'
2
+ import { AuthContext } from './auth.context'
3
+
4
+ declare module 'fastify' {
5
+ interface FastifyRequest {
6
+ user: AuthContext
7
+ }
8
+ }
@@ -0,0 +1,3 @@
1
+ export * from './mongo'
2
+ export * from './route'
3
+ export * from './auth.context'
@@ -0,0 +1,12 @@
1
+ import { Types } from 'mongoose'
2
+
3
+ export type WithId<T> = T & {
4
+ _id: Types.ObjectId
5
+ }
6
+
7
+ export type WithTimestamps<T> = T & {
8
+ createdAt: Date
9
+ updatedAt: Date
10
+ }
11
+
12
+ export type MongoEntity<T> = WithId<WithTimestamps<T>>
@@ -0,0 +1,35 @@
1
+ import { FastifyReply, FastifyRequest, RouteGenericInterface } from 'fastify'
2
+
3
+ export interface AppRouteGeneric<
4
+ TBody = unknown,
5
+ TQuery = unknown,
6
+ TParams = unknown,
7
+ THeaders = unknown,
8
+ > extends RouteGenericInterface {
9
+ Body: TBody
10
+ Querystring: TQuery
11
+ Params: TParams
12
+ Headers: THeaders
13
+ }
14
+
15
+ export type RouteHandler<T extends RouteGenericInterface> = (
16
+ req: FastifyRequest<T>,
17
+ reply: FastifyReply,
18
+ ) => Promise<unknown> | unknown
19
+
20
+ export type AppRouteHandler<
21
+ TBody = unknown,
22
+ TQuery = unknown,
23
+ TParams = unknown,
24
+ THeaders = unknown,
25
+ > = RouteHandler<AppRouteGeneric<TBody, TQuery, TParams, THeaders>>
26
+
27
+ type ParamsWithId = {
28
+ id: string
29
+ }
30
+
31
+ export type RHWithParamsId<TBody = unknown, TQuery = unknown> = AppRouteHandler<
32
+ TBody,
33
+ TQuery,
34
+ ParamsWithId
35
+ >
@@ -0,0 +1,2 @@
1
+ export * from './jwt'
2
+ export * from './validate'
@@ -0,0 +1,26 @@
1
+ import { env } from '@config'
2
+ import { InternalServerException } from '@errors'
3
+ import jwt, { SignOptions } from 'jsonwebtoken'
4
+
5
+ export type TokenPayload = {
6
+ userId: string
7
+ role?: string
8
+ }
9
+
10
+ export const signToken = (payload: TokenPayload, secret = env.JWT_SECRET): string => {
11
+ const options: SignOptions = {
12
+ expiresIn: env.JWT_EXPIRES_IN as SignOptions['expiresIn'],
13
+ }
14
+
15
+ return jwt.sign(payload, secret, options)
16
+ }
17
+
18
+ export const verifyToken = (token: string): TokenPayload => {
19
+ const decoded = jwt.verify(token, env.JWT_SECRET)
20
+
21
+ if (typeof decoded === 'string') {
22
+ throw new InternalServerException('Invalid token')
23
+ }
24
+
25
+ return decoded as TokenPayload
26
+ }
@@ -0,0 +1,11 @@
1
+ import { AnySchema, InferType } from 'yup'
2
+
3
+ export const validateSchema = async <TSchema extends AnySchema>(
4
+ schema: TSchema,
5
+ data: unknown,
6
+ ): Promise<InferType<TSchema>> => {
7
+ return schema.validate(data, {
8
+ abortEarly: false,
9
+ stripUnknown: true,
10
+ })
11
+ }
@@ -0,0 +1,3 @@
1
+ export * from './pageable.schema'
2
+ export * from './number.utils'
3
+ export * from './objectId'
@@ -0,0 +1,15 @@
1
+ export const toNumber = (defaultValue: number) => (_: unknown, originalValue: unknown) => {
2
+ const parsed = Number(originalValue)
3
+ return isNaN(parsed) ? defaultValue : parsed
4
+ }
5
+
6
+ export const toClampedNumber =
7
+ (min: number, max: number, defaultValue: number) => (_: unknown, originalValue: unknown) => {
8
+ const parsed = Number(originalValue)
9
+
10
+ if (isNaN(parsed)) return defaultValue
11
+ if (parsed > max) return max
12
+ if (parsed < min) return min
13
+
14
+ return parsed
15
+ }
@@ -0,0 +1,6 @@
1
+ import { ObjectId } from 'mongodb'
2
+
3
+ export const isValidObjectId = (value?: string): boolean => {
4
+ if (!value) return false
5
+ return ObjectId.isValid(value) && String(new ObjectId(value)) === value
6
+ }
@@ -0,0 +1,11 @@
1
+ import { object, number } from 'yup'
2
+ import { toNumber, toClampedNumber } from './number.utils'
3
+
4
+ export const pageableSchema = object({
5
+ page: number().transform(toNumber(1)).min(1).default(1).optional(),
6
+
7
+ limit: number()
8
+ .transform(toClampedNumber(1, 100, 10))
9
+ .default(10)
10
+ .optional(),
11
+ })
@@ -0,0 +1,9 @@
1
+ import * as yup from 'yup'
2
+ import { isValidObjectId } from './objectId'
3
+
4
+ yup.addMethod(yup.string, 'isObjectId', function (message?: string) {
5
+ return this.test('is-object-id', message || 'Invalid ObjectId', (value) => {
6
+ if (!value) return true
7
+ return isValidObjectId(value)
8
+ })
9
+ })
@@ -0,0 +1,41 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "CommonJS",
5
+ "moduleResolution": "node",
6
+ "baseUrl": ".",
7
+ "rootDir": "src",
8
+ "paths": {
9
+ "@errors": ["./src/shared/errors/index.ts"],
10
+ "@errors/*": ["./src/shared/errors/*"],
11
+ "@constants": ["./src/shared/constants/index.ts"],
12
+ "@constants/*": ["./src/shared/constants/*"],
13
+ "@handlers": ["./src/shared/handlers/index.ts"],
14
+ "@handlers/*": ["./src/shared/handlers/*"],
15
+ "@helpers": ["./src/shared/helpers/index.ts"],
16
+ "@helpers/*": ["./src/shared/helpers/*"],
17
+ "@middlewares": ["./src/shared/middlewares/index.ts"],
18
+ "@middlewares/*": ["./src/shared/middlewares/*"],
19
+ "@types": ["./src/shared/types/index.ts"],
20
+ "@types/*": ["./src/shared/types/*"],
21
+ "@utils": ["./src/shared/utils/index.ts"],
22
+ "@utils/*": ["./src/shared/utils/*"],
23
+ "@validators": ["./src/shared/validators/index.ts"],
24
+ "@validators/*": ["./src/shared/validators/*"],
25
+ "@config": ["./src/config/index.ts"],
26
+ "@config/*": ["./src/config/*"],
27
+ "@Health": ["./src/modules/health/index.ts"],
28
+ "@Health/*": ["./src/modules/health/*"],
29
+ "@Example": ["./src/modules/example/index.ts"],
30
+ "@Example/*": ["./src/modules/example/*"],
31
+ "@routes": ["./src/routes/index.ts"],
32
+ "@routes/*": ["./src/routes/*"]
33
+ },
34
+ "outDir": "dist",
35
+ "esModuleInterop": true,
36
+ "strict": true,
37
+ "skipLibCheck": true
38
+ },
39
+ "include": ["src/**/*.ts"],
40
+ "exclude": ["dist", "node_modules"]
41
+ }