ar-saas 0.1.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.
- package/README.md +62 -0
- package/dist/cli.js +67 -0
- package/dist/generator.js +242 -0
- package/dist/index.js +13 -0
- package/dist/license.js +71 -0
- package/package.json +46 -0
- package/templates/backend/.env.example +67 -0
- package/templates/backend/.prettierrc +4 -0
- package/templates/backend/README.md +168 -0
- package/templates/backend/eslint.config.mjs +35 -0
- package/templates/backend/nest-cli.json +8 -0
- package/templates/backend/package-lock.json +10979 -0
- package/templates/backend/package.json +88 -0
- package/templates/backend/src/app.controller.spec.ts +24 -0
- package/templates/backend/src/app.controller.ts +15 -0
- package/templates/backend/src/app.module.ts +40 -0
- package/templates/backend/src/app.service.ts +11 -0
- package/templates/backend/src/common/base/base.repository.ts +221 -0
- package/templates/backend/src/common/base/base.schema.ts +24 -0
- package/templates/backend/src/common/decorators/cookie.decorator.ts +9 -0
- package/templates/backend/src/common/decorators/current-user.decorator.ts +20 -0
- package/templates/backend/src/common/decorators/workspace-id.decorator.ts +14 -0
- package/templates/backend/src/common/filters/global-exception.filter.ts +61 -0
- package/templates/backend/src/common/guards/jwt-auth.guard.ts +5 -0
- package/templates/backend/src/common/interceptors/workspace-tenant.interceptor.ts +45 -0
- package/templates/backend/src/main.ts +51 -0
- package/templates/backend/src/modules/auth/auth.controller.ts +158 -0
- package/templates/backend/src/modules/auth/auth.module.ts +20 -0
- package/templates/backend/src/modules/auth/auth.service.ts +257 -0
- package/templates/backend/src/modules/auth/dto/forgot-password.dto.ts +9 -0
- package/templates/backend/src/modules/auth/dto/login.dto.ts +14 -0
- package/templates/backend/src/modules/auth/dto/refresh-token.dto.ts +12 -0
- package/templates/backend/src/modules/auth/dto/register.dto.ts +26 -0
- package/templates/backend/src/modules/auth/dto/reset-password.dto.ts +16 -0
- package/templates/backend/src/modules/auth/dto/verify-email.dto.ts +9 -0
- package/templates/backend/src/modules/auth/strategies/jwt.strategy.ts +43 -0
- package/templates/backend/src/modules/mail/mail.module.ts +9 -0
- package/templates/backend/src/modules/mail/mail.service.ts +141 -0
- package/templates/backend/src/modules/users/schemas/user.schema.ts +54 -0
- package/templates/backend/src/modules/users/users.module.ts +14 -0
- package/templates/backend/src/modules/users/users.repository.ts +51 -0
- package/templates/backend/src/modules/users/users.service.ts +104 -0
- package/templates/backend/src/modules/workspaces/schemas/workspace.schema.ts +26 -0
- package/templates/backend/src/modules/workspaces/workspaces.module.ts +16 -0
- package/templates/backend/src/modules/workspaces/workspaces.repository.ts +34 -0
- package/templates/backend/src/modules/workspaces/workspaces.service.ts +42 -0
- package/templates/backend/test/app.e2e-spec.ts +25 -0
- package/templates/backend/test/jest-e2e.json +9 -0
- package/templates/backend/tsconfig.build.json +4 -0
- package/templates/backend/tsconfig.json +26 -0
- package/templates/frontend/.env.local.example +1 -0
- package/templates/frontend/components.json +20 -0
- package/templates/frontend/eslint.config.mjs +14 -0
- package/templates/frontend/next.config.ts +5 -0
- package/templates/frontend/package-lock.json +6722 -0
- package/templates/frontend/package.json +40 -0
- package/templates/frontend/postcss.config.mjs +7 -0
- package/templates/frontend/src/app/(auth)/forgot-password/page.tsx +84 -0
- package/templates/frontend/src/app/(auth)/layout.tsx +28 -0
- package/templates/frontend/src/app/(auth)/login/page.tsx +111 -0
- package/templates/frontend/src/app/(auth)/register/page.tsx +119 -0
- package/templates/frontend/src/app/(auth)/reset-password/page.tsx +120 -0
- package/templates/frontend/src/app/(auth)/verify-email/page.tsx +78 -0
- package/templates/frontend/src/app/(dashboard)/dashboard/page.tsx +36 -0
- package/templates/frontend/src/app/(dashboard)/layout.tsx +59 -0
- package/templates/frontend/src/app/globals.css +81 -0
- package/templates/frontend/src/app/layout.tsx +26 -0
- package/templates/frontend/src/app/page.tsx +5 -0
- package/templates/frontend/src/app/setup/page.tsx +278 -0
- package/templates/frontend/src/components/ui/button.tsx +52 -0
- package/templates/frontend/src/components/ui/card.tsx +50 -0
- package/templates/frontend/src/components/ui/form.tsx +158 -0
- package/templates/frontend/src/components/ui/input.tsx +21 -0
- package/templates/frontend/src/components/ui/label.tsx +22 -0
- package/templates/frontend/src/components/ui/toast.tsx +109 -0
- package/templates/frontend/src/components/ui/toaster.tsx +30 -0
- package/templates/frontend/src/hooks/use-toast.ts +116 -0
- package/templates/frontend/src/lib/api/auth.ts +39 -0
- package/templates/frontend/src/lib/api/client.ts +66 -0
- package/templates/frontend/src/lib/hooks/use-auth.ts +1 -0
- package/templates/frontend/src/lib/utils.ts +6 -0
- package/templates/frontend/src/providers/auth-provider.tsx +60 -0
- package/templates/frontend/src/types/api.ts +12 -0
- package/templates/frontend/src/types/auth.ts +27 -0
- package/templates/frontend/tsconfig.json +23 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-saas-ar-backend",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Backend SaaS multi-tenant generado con create-saas-ar",
|
|
5
|
+
"author": "",
|
|
6
|
+
"private": true,
|
|
7
|
+
"license": "UNLICENSED",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "nest build",
|
|
10
|
+
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
|
11
|
+
"start": "nest start",
|
|
12
|
+
"start:dev": "nest start --watch",
|
|
13
|
+
"start:debug": "nest start --debug --watch",
|
|
14
|
+
"start:prod": "node dist/main",
|
|
15
|
+
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
|
16
|
+
"test": "jest",
|
|
17
|
+
"test:watch": "jest --watch",
|
|
18
|
+
"test:cov": "jest --coverage",
|
|
19
|
+
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
|
20
|
+
"test:e2e": "jest --config ./test/jest-e2e.json"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@nestjs/common": "^11.0.1",
|
|
24
|
+
"@nestjs/config": "^4.0.2",
|
|
25
|
+
"@nestjs/core": "^11.0.1",
|
|
26
|
+
"@nestjs/jwt": "^11.0.1",
|
|
27
|
+
"@nestjs/mongoose": "^11.0.2",
|
|
28
|
+
"@nestjs/passport": "^11.0.5",
|
|
29
|
+
"@nestjs/platform-express": "^11.0.1",
|
|
30
|
+
"@nestjs/schedule": "^6.0.1",
|
|
31
|
+
"@nestjs/swagger": "^11.2.1",
|
|
32
|
+
"bcryptjs": "^2.4.3",
|
|
33
|
+
"class-transformer": "^0.5.1",
|
|
34
|
+
"class-validator": "^0.14.2",
|
|
35
|
+
"cookie-parser": "^1.4.7",
|
|
36
|
+
"mongoose": "^9.0.1",
|
|
37
|
+
"passport": "^0.7.0",
|
|
38
|
+
"passport-jwt": "^4.0.1",
|
|
39
|
+
"reflect-metadata": "^0.2.2",
|
|
40
|
+
"resend": "^4.1.2",
|
|
41
|
+
"rxjs": "^7.8.1"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@eslint/eslintrc": "^3.2.0",
|
|
45
|
+
"@eslint/js": "^9.18.0",
|
|
46
|
+
"@nestjs/cli": "^11.0.0",
|
|
47
|
+
"@nestjs/schematics": "^11.0.0",
|
|
48
|
+
"@nestjs/testing": "^11.0.1",
|
|
49
|
+
"@types/bcryptjs": "^2.4.6",
|
|
50
|
+
"@types/cookie-parser": "^1.4.8",
|
|
51
|
+
"@types/express": "^5.0.0",
|
|
52
|
+
"@types/jest": "^30.0.0",
|
|
53
|
+
"@types/node": "^22.10.7",
|
|
54
|
+
"@types/passport-jwt": "^4.0.1",
|
|
55
|
+
"@types/supertest": "^6.0.2",
|
|
56
|
+
"eslint": "^9.18.0",
|
|
57
|
+
"eslint-config-prettier": "^10.0.1",
|
|
58
|
+
"eslint-plugin-prettier": "^5.2.2",
|
|
59
|
+
"globals": "^16.0.0",
|
|
60
|
+
"jest": "^30.0.0",
|
|
61
|
+
"prettier": "^3.4.2",
|
|
62
|
+
"source-map-support": "^0.5.21",
|
|
63
|
+
"supertest": "^7.0.0",
|
|
64
|
+
"ts-jest": "^29.2.5",
|
|
65
|
+
"ts-loader": "^9.5.2",
|
|
66
|
+
"ts-node": "^10.9.2",
|
|
67
|
+
"tsconfig-paths": "^4.2.0",
|
|
68
|
+
"typescript": "^5.7.3",
|
|
69
|
+
"typescript-eslint": "^8.20.0"
|
|
70
|
+
},
|
|
71
|
+
"jest": {
|
|
72
|
+
"moduleFileExtensions": [
|
|
73
|
+
"js",
|
|
74
|
+
"json",
|
|
75
|
+
"ts"
|
|
76
|
+
],
|
|
77
|
+
"rootDir": "src",
|
|
78
|
+
"testRegex": ".*\\.spec\\.ts$",
|
|
79
|
+
"transform": {
|
|
80
|
+
"^.+\\.(t|j)s$": "ts-jest"
|
|
81
|
+
},
|
|
82
|
+
"collectCoverageFrom": [
|
|
83
|
+
"**/*.(t|j)s"
|
|
84
|
+
],
|
|
85
|
+
"coverageDirectory": "../coverage",
|
|
86
|
+
"testEnvironment": "node"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
2
|
+
import { AppController } from './app.controller';
|
|
3
|
+
import { AppService } from './app.service';
|
|
4
|
+
|
|
5
|
+
describe('AppController', () => {
|
|
6
|
+
let appController: AppController;
|
|
7
|
+
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
const app: TestingModule = await Test.createTestingModule({
|
|
10
|
+
controllers: [AppController],
|
|
11
|
+
providers: [AppService],
|
|
12
|
+
}).compile();
|
|
13
|
+
|
|
14
|
+
appController = app.get<AppController>(AppController);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe('health', () => {
|
|
18
|
+
it('should return status ok', () => {
|
|
19
|
+
const result = appController.getHealth();
|
|
20
|
+
expect(result.status).toBe('ok');
|
|
21
|
+
expect(result.timestamp).toBeDefined();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller, Get } from '@nestjs/common';
|
|
2
|
+
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
|
3
|
+
import { AppService } from './app.service';
|
|
4
|
+
|
|
5
|
+
@ApiTags('Health')
|
|
6
|
+
@Controller()
|
|
7
|
+
export class AppController {
|
|
8
|
+
constructor(private readonly appService: AppService) {}
|
|
9
|
+
|
|
10
|
+
@Get()
|
|
11
|
+
@ApiOperation({ summary: 'Health check' })
|
|
12
|
+
getHealth(): { status: string; timestamp: string } {
|
|
13
|
+
return this.appService.getHealth();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule, ConfigService } from '@nestjs/config';
|
|
3
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
4
|
+
import { APP_FILTER } from '@nestjs/core';
|
|
5
|
+
import { AppController } from './app.controller';
|
|
6
|
+
import { AppService } from './app.service';
|
|
7
|
+
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
|
8
|
+
import { AuthModule } from './modules/auth/auth.module';
|
|
9
|
+
import { MailModule } from './modules/mail/mail.module';
|
|
10
|
+
import { UsersModule } from './modules/users/users.module';
|
|
11
|
+
import { WorkspacesModule } from './modules/workspaces/workspaces.module';
|
|
12
|
+
|
|
13
|
+
@Module({
|
|
14
|
+
imports: [
|
|
15
|
+
ConfigModule.forRoot({
|
|
16
|
+
isGlobal: true,
|
|
17
|
+
envFilePath: '.env',
|
|
18
|
+
}),
|
|
19
|
+
MongooseModule.forRootAsync({
|
|
20
|
+
imports: [ConfigModule],
|
|
21
|
+
inject: [ConfigService],
|
|
22
|
+
useFactory: (config: ConfigService) => ({
|
|
23
|
+
uri: config.getOrThrow<string>('MONGODB_URI'),
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
MailModule,
|
|
27
|
+
UsersModule,
|
|
28
|
+
WorkspacesModule,
|
|
29
|
+
AuthModule,
|
|
30
|
+
],
|
|
31
|
+
controllers: [AppController],
|
|
32
|
+
providers: [
|
|
33
|
+
AppService,
|
|
34
|
+
{
|
|
35
|
+
provide: APP_FILTER,
|
|
36
|
+
useClass: GlobalExceptionFilter,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
export class AppModule {}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BadRequestException,
|
|
3
|
+
InternalServerErrorException,
|
|
4
|
+
} from '@nestjs/common';
|
|
5
|
+
import { Model, Document, Types } from 'mongoose';
|
|
6
|
+
import type { QueryFilter, UpdateQuery, PipelineStage } from 'mongoose';
|
|
7
|
+
|
|
8
|
+
export interface PaginatedResult<T> {
|
|
9
|
+
data: T[];
|
|
10
|
+
total: number;
|
|
11
|
+
page: number;
|
|
12
|
+
limit: number;
|
|
13
|
+
totalPages: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class BaseRepository<T extends Document> {
|
|
17
|
+
constructor(protected readonly model: Model<T>) {}
|
|
18
|
+
|
|
19
|
+
protected toObjectId(id: string): Types.ObjectId {
|
|
20
|
+
if (!Types.ObjectId.isValid(id)) {
|
|
21
|
+
throw new BadRequestException(`ID inválido: ${id}`);
|
|
22
|
+
}
|
|
23
|
+
return new Types.ObjectId(id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async findAll(
|
|
27
|
+
workspaceId: string,
|
|
28
|
+
options?: {
|
|
29
|
+
filter?: Record<string, unknown>;
|
|
30
|
+
limit?: number;
|
|
31
|
+
skip?: number;
|
|
32
|
+
sort?: Record<string, 1 | -1>;
|
|
33
|
+
},
|
|
34
|
+
): Promise<T[]> {
|
|
35
|
+
const query = this.buildBaseQuery(workspaceId, options?.filter);
|
|
36
|
+
return this.model
|
|
37
|
+
.find(query)
|
|
38
|
+
.limit(options?.limit ?? 100)
|
|
39
|
+
.skip(options?.skip ?? 0)
|
|
40
|
+
.sort(options?.sort ?? { createdAt: -1 })
|
|
41
|
+
.lean()
|
|
42
|
+
.exec() as unknown as T[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async findById(workspaceId: string, id: string): Promise<T | null> {
|
|
46
|
+
return this.model
|
|
47
|
+
.findOne({
|
|
48
|
+
_id: this.toObjectId(id),
|
|
49
|
+
workspaceId: this.toObjectId(workspaceId),
|
|
50
|
+
deletedAt: null,
|
|
51
|
+
})
|
|
52
|
+
.lean()
|
|
53
|
+
.exec() as unknown as T | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async findOne(
|
|
57
|
+
workspaceId: string,
|
|
58
|
+
filter: Record<string, unknown>,
|
|
59
|
+
): Promise<T | null> {
|
|
60
|
+
return this.model
|
|
61
|
+
.findOne({
|
|
62
|
+
...filter,
|
|
63
|
+
workspaceId: this.toObjectId(workspaceId),
|
|
64
|
+
deletedAt: null,
|
|
65
|
+
})
|
|
66
|
+
.lean()
|
|
67
|
+
.exec() as unknown as T | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async create(data: Partial<T>): Promise<T> {
|
|
71
|
+
try {
|
|
72
|
+
const created = await this.model.create(data);
|
|
73
|
+
return created.toObject() as unknown as T;
|
|
74
|
+
} catch (error: unknown) {
|
|
75
|
+
this.handleMongoError(error);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async update(
|
|
81
|
+
workspaceId: string,
|
|
82
|
+
id: string,
|
|
83
|
+
data: UpdateQuery<T>,
|
|
84
|
+
): Promise<T | null> {
|
|
85
|
+
return this.model
|
|
86
|
+
.findOneAndUpdate(
|
|
87
|
+
{
|
|
88
|
+
_id: this.toObjectId(id),
|
|
89
|
+
workspaceId: this.toObjectId(workspaceId),
|
|
90
|
+
deletedAt: null,
|
|
91
|
+
},
|
|
92
|
+
{ $set: data },
|
|
93
|
+
{ new: true, runValidators: true },
|
|
94
|
+
)
|
|
95
|
+
.lean()
|
|
96
|
+
.exec() as unknown as T | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async softDelete(workspaceId: string, id: string): Promise<T | null> {
|
|
100
|
+
return this.model
|
|
101
|
+
.findOneAndUpdate(
|
|
102
|
+
{
|
|
103
|
+
_id: this.toObjectId(id),
|
|
104
|
+
workspaceId: this.toObjectId(workspaceId),
|
|
105
|
+
deletedAt: null,
|
|
106
|
+
},
|
|
107
|
+
{ $set: { deletedAt: new Date() } },
|
|
108
|
+
{ new: true },
|
|
109
|
+
)
|
|
110
|
+
.lean()
|
|
111
|
+
.exec() as unknown as T | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async restore(workspaceId: string, id: string): Promise<T | null> {
|
|
115
|
+
return this.model
|
|
116
|
+
.findOneAndUpdate(
|
|
117
|
+
{
|
|
118
|
+
_id: this.toObjectId(id),
|
|
119
|
+
workspaceId: this.toObjectId(workspaceId),
|
|
120
|
+
deletedAt: { $ne: null },
|
|
121
|
+
},
|
|
122
|
+
{ $set: { deletedAt: null } },
|
|
123
|
+
{ new: true },
|
|
124
|
+
)
|
|
125
|
+
.lean()
|
|
126
|
+
.exec() as unknown as T | null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async count(
|
|
130
|
+
workspaceId: string,
|
|
131
|
+
filter?: Record<string, unknown>,
|
|
132
|
+
): Promise<number> {
|
|
133
|
+
const query = this.buildBaseQuery(workspaceId, filter);
|
|
134
|
+
return this.model.countDocuments(query).exec();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async exists(
|
|
138
|
+
workspaceId: string,
|
|
139
|
+
filter: Record<string, unknown>,
|
|
140
|
+
): Promise<boolean> {
|
|
141
|
+
const count = await this.model.countDocuments({
|
|
142
|
+
...filter,
|
|
143
|
+
workspaceId: this.toObjectId(workspaceId),
|
|
144
|
+
deletedAt: null,
|
|
145
|
+
});
|
|
146
|
+
return count > 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async paginate(
|
|
150
|
+
workspaceId: string,
|
|
151
|
+
options: {
|
|
152
|
+
page?: number;
|
|
153
|
+
limit?: number;
|
|
154
|
+
filter?: Record<string, unknown>;
|
|
155
|
+
sort?: Record<string, 1 | -1>;
|
|
156
|
+
},
|
|
157
|
+
): Promise<PaginatedResult<T>> {
|
|
158
|
+
const page = options.page ?? 1;
|
|
159
|
+
const limit = options.limit ?? 20;
|
|
160
|
+
const skip = (page - 1) * limit;
|
|
161
|
+
const query = this.buildBaseQuery(workspaceId, options.filter);
|
|
162
|
+
|
|
163
|
+
const [data, total] = await Promise.all([
|
|
164
|
+
this.model
|
|
165
|
+
.find(query)
|
|
166
|
+
.sort(options.sort ?? { createdAt: -1 })
|
|
167
|
+
.skip(skip)
|
|
168
|
+
.limit(limit)
|
|
169
|
+
.lean()
|
|
170
|
+
.exec(),
|
|
171
|
+
this.model.countDocuments(query).exec(),
|
|
172
|
+
]);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
data: data as unknown as T[],
|
|
176
|
+
total,
|
|
177
|
+
page,
|
|
178
|
+
limit,
|
|
179
|
+
totalPages: Math.ceil(total / limit),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async aggregate(pipeline: PipelineStage[]): Promise<unknown[]> {
|
|
184
|
+
return this.model.aggregate(pipeline).exec();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private buildBaseQuery(
|
|
188
|
+
workspaceId: string,
|
|
189
|
+
additionalFilter?: Record<string, unknown>,
|
|
190
|
+
): QueryFilter<T> {
|
|
191
|
+
return {
|
|
192
|
+
workspaceId: this.toObjectId(workspaceId),
|
|
193
|
+
deletedAt: null,
|
|
194
|
+
...additionalFilter,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private handleMongoError(error: unknown): never {
|
|
199
|
+
const mongoError = error as {
|
|
200
|
+
code?: number;
|
|
201
|
+
keyValue?: Record<string, unknown>;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
if (mongoError.code === 11000) {
|
|
205
|
+
const fields = Object.keys(mongoError.keyValue ?? {}).join(', ');
|
|
206
|
+
throw new BadRequestException(
|
|
207
|
+
`Ya existe un registro con los mismos valores en: ${fields}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (mongoError.code === 121) {
|
|
212
|
+
throw new BadRequestException(
|
|
213
|
+
'Error de validación del schema de MongoDB',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
throw new InternalServerErrorException(
|
|
218
|
+
'Error inesperado en la base de datos',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Prop, Schema } from '@nestjs/mongoose';
|
|
2
|
+
import { Types } from 'mongoose';
|
|
3
|
+
|
|
4
|
+
@Schema({ timestamps: true })
|
|
5
|
+
export class BaseSchema {
|
|
6
|
+
@Prop({
|
|
7
|
+
type: Types.ObjectId,
|
|
8
|
+
required: true,
|
|
9
|
+
index: true,
|
|
10
|
+
})
|
|
11
|
+
workspaceId!: Types.ObjectId;
|
|
12
|
+
|
|
13
|
+
@Prop({
|
|
14
|
+
type: Types.ObjectId,
|
|
15
|
+
required: true,
|
|
16
|
+
})
|
|
17
|
+
createdBy!: Types.ObjectId;
|
|
18
|
+
|
|
19
|
+
@Prop({
|
|
20
|
+
type: Date,
|
|
21
|
+
default: null,
|
|
22
|
+
})
|
|
23
|
+
deletedAt!: Date | null;
|
|
24
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import { Request } from 'express';
|
|
3
|
+
|
|
4
|
+
export const Cookie = createParamDecorator(
|
|
5
|
+
(name: string, ctx: ExecutionContext): string | undefined => {
|
|
6
|
+
const request = ctx.switchToHttp().getRequest<Request>();
|
|
7
|
+
return (request.cookies as Record<string, string>)?.[name];
|
|
8
|
+
},
|
|
9
|
+
);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
export interface TokenPayload {
|
|
4
|
+
userId: string;
|
|
5
|
+
email: string;
|
|
6
|
+
workspaceId: string;
|
|
7
|
+
role: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const CurrentUser = createParamDecorator(
|
|
11
|
+
(_data: unknown, ctx: ExecutionContext): TokenPayload => {
|
|
12
|
+
const request = ctx.switchToHttp().getRequest();
|
|
13
|
+
if (!request.user) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
'Usuario no encontrado en el request. ¿Falta el AuthGuard?',
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return request.user as TokenPayload;
|
|
19
|
+
},
|
|
20
|
+
);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import { TenantRequest } from '../interceptors/workspace-tenant.interceptor';
|
|
3
|
+
|
|
4
|
+
export const WorkspaceId = createParamDecorator(
|
|
5
|
+
(_data: unknown, ctx: ExecutionContext): string => {
|
|
6
|
+
const request = ctx.switchToHttp().getRequest<TenantRequest>();
|
|
7
|
+
if (!request.workspaceId) {
|
|
8
|
+
throw new Error(
|
|
9
|
+
'workspaceId no encontrado en el request. ¿Falta el WorkspaceTenantInterceptor?',
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
return request.workspaceId;
|
|
13
|
+
},
|
|
14
|
+
);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ExceptionFilter,
|
|
3
|
+
Catch,
|
|
4
|
+
ArgumentsHost,
|
|
5
|
+
HttpException,
|
|
6
|
+
HttpStatus,
|
|
7
|
+
Logger,
|
|
8
|
+
} from '@nestjs/common';
|
|
9
|
+
import { Request, Response } from 'express';
|
|
10
|
+
|
|
11
|
+
@Catch()
|
|
12
|
+
export class GlobalExceptionFilter implements ExceptionFilter {
|
|
13
|
+
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
|
14
|
+
|
|
15
|
+
catch(exception: unknown, host: ArgumentsHost): void {
|
|
16
|
+
const ctx = host.switchToHttp();
|
|
17
|
+
const response = ctx.getResponse<Response>();
|
|
18
|
+
const request = ctx.getRequest<Request>();
|
|
19
|
+
|
|
20
|
+
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
|
21
|
+
let message = 'Error interno del servidor';
|
|
22
|
+
let error = 'Internal Server Error';
|
|
23
|
+
|
|
24
|
+
if (exception instanceof HttpException) {
|
|
25
|
+
status = exception.getStatus();
|
|
26
|
+
const exceptionResponse = exception.getResponse();
|
|
27
|
+
|
|
28
|
+
if (typeof exceptionResponse === 'string') {
|
|
29
|
+
message = exceptionResponse;
|
|
30
|
+
error = exception.message;
|
|
31
|
+
} else if (typeof exceptionResponse === 'object') {
|
|
32
|
+
const resp = exceptionResponse as Record<string, unknown>;
|
|
33
|
+
message =
|
|
34
|
+
typeof resp.message === 'string'
|
|
35
|
+
? resp.message
|
|
36
|
+
: Array.isArray(resp.message)
|
|
37
|
+
? resp.message.join('; ')
|
|
38
|
+
: exception.message;
|
|
39
|
+
error = (resp.error as string) ?? exception.message;
|
|
40
|
+
}
|
|
41
|
+
} else if (exception instanceof Error) {
|
|
42
|
+
message = exception.message;
|
|
43
|
+
error = exception.name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (status >= 500) {
|
|
47
|
+
this.logger.error(
|
|
48
|
+
`${request.method} ${request.url} → ${status}`,
|
|
49
|
+
exception instanceof Error ? exception.stack : undefined,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
response.status(status).json({
|
|
54
|
+
statusCode: status,
|
|
55
|
+
message,
|
|
56
|
+
error,
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
path: request.url,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
NestInterceptor,
|
|
4
|
+
ExecutionContext,
|
|
5
|
+
CallHandler,
|
|
6
|
+
BadRequestException,
|
|
7
|
+
} from '@nestjs/common';
|
|
8
|
+
import { Observable } from 'rxjs';
|
|
9
|
+
import { Request } from 'express';
|
|
10
|
+
|
|
11
|
+
export interface TenantRequest extends Request {
|
|
12
|
+
workspaceId?: string;
|
|
13
|
+
user?: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Injectable()
|
|
17
|
+
export class WorkspaceTenantInterceptor implements NestInterceptor {
|
|
18
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
|
19
|
+
const request = context.switchToHttp().getRequest<TenantRequest>();
|
|
20
|
+
|
|
21
|
+
const headerWorkspaceId = request.headers['x-workspace-id'];
|
|
22
|
+
if (headerWorkspaceId && typeof headerWorkspaceId === 'string') {
|
|
23
|
+
request.workspaceId = headerWorkspaceId;
|
|
24
|
+
return next.handle();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const workspaceIdFromUser =
|
|
28
|
+
typeof request.user?.workspaceId === 'string'
|
|
29
|
+
? request.user.workspaceId
|
|
30
|
+
: undefined;
|
|
31
|
+
if (workspaceIdFromUser) {
|
|
32
|
+
request.workspaceId = workspaceIdFromUser;
|
|
33
|
+
return next.handle();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const apiPrefix = process.env.API_PREFIX ?? 'api';
|
|
37
|
+
if (request.path.startsWith(`/${apiPrefix}/auth/`)) {
|
|
38
|
+
return next.handle();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new BadRequestException(
|
|
42
|
+
'Se requiere workspaceId en header x-workspace-id o token JWT',
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { NestFactory } from '@nestjs/core';
|
|
2
|
+
import { ValidationPipe } from '@nestjs/common';
|
|
3
|
+
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
|
4
|
+
import cookieParser from 'cookie-parser';
|
|
5
|
+
import { AppModule } from './app.module';
|
|
6
|
+
import { WorkspaceTenantInterceptor } from './common/interceptors/workspace-tenant.interceptor';
|
|
7
|
+
|
|
8
|
+
async function bootstrap() {
|
|
9
|
+
const app = await NestFactory.create(AppModule);
|
|
10
|
+
|
|
11
|
+
const apiPrefix = process.env.API_PREFIX ?? 'api';
|
|
12
|
+
app.setGlobalPrefix(apiPrefix);
|
|
13
|
+
|
|
14
|
+
app.use(cookieParser(process.env.COOKIE_SECRET));
|
|
15
|
+
|
|
16
|
+
app.enableCors({
|
|
17
|
+
origin: process.env.CORS_ORIGINS?.split(',') ?? ['http://localhost:5173'],
|
|
18
|
+
credentials: true,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
app.useGlobalPipes(
|
|
22
|
+
new ValidationPipe({
|
|
23
|
+
whitelist: true,
|
|
24
|
+
forbidNonWhitelisted: true,
|
|
25
|
+
transform: true,
|
|
26
|
+
transformOptions: {
|
|
27
|
+
enableImplicitConversion: true,
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
app.useGlobalInterceptors(new WorkspaceTenantInterceptor());
|
|
33
|
+
|
|
34
|
+
if (process.env.SWAGGER_ENABLED === 'true') {
|
|
35
|
+
const config = new DocumentBuilder()
|
|
36
|
+
.setTitle('create-saas-ar API')
|
|
37
|
+
.setDescription('Backend SaaS multi-tenant')
|
|
38
|
+
.setVersion('1.0')
|
|
39
|
+
.addCookieAuth('access_token')
|
|
40
|
+
.build();
|
|
41
|
+
|
|
42
|
+
const document = SwaggerModule.createDocument(app, config);
|
|
43
|
+
SwaggerModule.setup(`${apiPrefix}/docs`, app, document);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const port = process.env.PORT ?? 3000;
|
|
47
|
+
await app.listen(port);
|
|
48
|
+
console.log(`🚀 Servidor corriendo en http://localhost:${port}/${apiPrefix}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
void bootstrap();
|