ar-saas 0.4.3 → 0.5.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/package.json +1 -1
- package/templates/backend/.env.example +13 -3
- package/templates/backend/README.md +22 -3
- package/templates/backend/package-lock.json +165 -2
- package/templates/backend/package.json +2 -0
- package/templates/backend/src/app.module.ts +14 -0
- package/templates/backend/src/common/guards/github-auth.guard.ts +5 -0
- package/templates/backend/src/main.ts +2 -2
- package/templates/backend/src/modules/auth/auth.controller.ts +51 -3
- package/templates/backend/src/modules/auth/auth.module.ts +2 -1
- package/templates/backend/src/modules/auth/auth.service.ts +96 -11
- package/templates/backend/src/modules/auth/strategies/github.strategy.ts +46 -0
- package/templates/backend/src/modules/clients/clients.controller.ts +91 -0
- package/templates/backend/src/modules/clients/clients.module.ts +16 -0
- package/templates/backend/src/modules/clients/clients.repository.ts +14 -0
- package/templates/backend/src/modules/clients/clients.service.ts +52 -0
- package/templates/backend/src/modules/clients/dto/create-client.dto.ts +40 -0
- package/templates/backend/src/modules/clients/dto/query-client.dto.ts +30 -0
- package/templates/backend/src/modules/clients/dto/update-client.dto.ts +4 -0
- package/templates/backend/src/modules/clients/schemas/client.schema.ts +32 -0
- package/templates/backend/src/modules/invoices/dto/create-invoice.dto.ts +79 -0
- package/templates/backend/src/modules/invoices/dto/invoice-item.dto.ts +23 -0
- package/templates/backend/src/modules/invoices/dto/query-invoice.dto.ts +40 -0
- package/templates/backend/src/modules/invoices/dto/update-invoice.dto.ts +4 -0
- package/templates/backend/src/modules/invoices/invoices.controller.ts +91 -0
- package/templates/backend/src/modules/invoices/invoices.module.ts +18 -0
- package/templates/backend/src/modules/invoices/invoices.repository.ts +14 -0
- package/templates/backend/src/modules/invoices/invoices.service.ts +104 -0
- package/templates/backend/src/modules/invoices/schemas/invoice.schema.ts +75 -0
- package/templates/backend/src/modules/notifications/dto/create-notification.dto.ts +45 -0
- package/templates/backend/src/modules/notifications/dto/query-notification.dto.ts +30 -0
- package/templates/backend/src/modules/notifications/dto/update-notification.dto.ts +4 -0
- package/templates/backend/src/modules/notifications/notifications.controller.ts +119 -0
- package/templates/backend/src/modules/notifications/notifications.module.ts +16 -0
- package/templates/backend/src/modules/notifications/notifications.repository.ts +31 -0
- package/templates/backend/src/modules/notifications/notifications.service.ts +64 -0
- package/templates/backend/src/modules/notifications/schemas/notification.schema.ts +38 -0
- package/templates/backend/src/modules/pipeline/dto/create-deal.dto.ts +40 -0
- package/templates/backend/src/modules/pipeline/dto/query-deal.dto.ts +35 -0
- package/templates/backend/src/modules/pipeline/dto/update-deal.dto.ts +4 -0
- package/templates/backend/src/modules/pipeline/pipeline.controller.ts +91 -0
- package/templates/backend/src/modules/pipeline/pipeline.module.ts +18 -0
- package/templates/backend/src/modules/pipeline/pipeline.repository.ts +14 -0
- package/templates/backend/src/modules/pipeline/pipeline.service.ts +64 -0
- package/templates/backend/src/modules/pipeline/schemas/deal.schema.ts +39 -0
- package/templates/backend/src/modules/planner/dto/create-planner-block.dto.ts +66 -0
- package/templates/backend/src/modules/planner/dto/query-planner-block.dto.ts +48 -0
- package/templates/backend/src/modules/planner/dto/update-block-status.dto.ts +10 -0
- package/templates/backend/src/modules/planner/dto/update-planner-block.dto.ts +4 -0
- package/templates/backend/src/modules/planner/planner.controller.ts +124 -0
- package/templates/backend/src/modules/planner/planner.module.ts +16 -0
- package/templates/backend/src/modules/planner/planner.repository.ts +45 -0
- package/templates/backend/src/modules/planner/planner.service.ts +104 -0
- package/templates/backend/src/modules/planner/schemas/planner-block.schema.ts +56 -0
- package/templates/backend/src/modules/task-columns/dto/create-task-column.dto.ts +20 -0
- package/templates/backend/src/modules/task-columns/dto/reorder-columns.dto.ts +9 -0
- package/templates/backend/src/modules/task-columns/dto/update-task-column.dto.ts +4 -0
- package/templates/backend/src/modules/task-columns/schemas/task-column.schema.ts +21 -0
- package/templates/backend/src/modules/task-columns/task-columns.controller.ts +86 -0
- package/templates/backend/src/modules/task-columns/task-columns.module.ts +16 -0
- package/templates/backend/src/modules/task-columns/task-columns.repository.ts +15 -0
- package/templates/backend/src/modules/task-columns/task-columns.service.ts +49 -0
- package/templates/backend/src/modules/tasks/dto/checklist-item.dto.ts +13 -0
- package/templates/backend/src/modules/tasks/dto/create-task.dto.ts +67 -0
- package/templates/backend/src/modules/tasks/dto/label.dto.ts +12 -0
- package/templates/backend/src/modules/tasks/dto/move-task.dto.ts +15 -0
- package/templates/backend/src/modules/tasks/dto/query-task.dto.ts +40 -0
- package/templates/backend/src/modules/tasks/dto/update-task.dto.ts +4 -0
- package/templates/backend/src/modules/tasks/schemas/task.schema.ts +66 -0
- package/templates/backend/src/modules/tasks/tasks.controller.ts +104 -0
- package/templates/backend/src/modules/tasks/tasks.module.ts +18 -0
- package/templates/backend/src/modules/tasks/tasks.repository.ts +14 -0
- package/templates/backend/src/modules/tasks/tasks.service.ts +76 -0
- package/templates/backend/src/modules/users/schemas/user.schema.ts +3 -0
- package/templates/backend/src/modules/users/users.repository.ts +8 -0
- package/templates/backend/src/modules/users/users.service.ts +34 -0
- package/templates/frontend/.env.local.example +1 -1
- package/templates/frontend/README.md +43 -1
- package/templates/frontend/package.json +48 -45
- package/templates/frontend/pnpm-lock.yaml +5096 -5012
- package/templates/frontend/src/app/(auth)/layout.tsx +7 -1
- package/templates/frontend/src/app/(auth)/login/page.tsx +13 -0
- package/templates/frontend/src/app/(auth)/register/page.tsx +13 -0
- package/templates/frontend/src/app/(dashboard)/clients/page.tsx +295 -0
- package/templates/frontend/src/app/(dashboard)/invoices/page.tsx +305 -0
- package/templates/frontend/src/app/(dashboard)/notifications/page.tsx +173 -0
- package/templates/frontend/src/app/(dashboard)/pipeline/page.tsx +244 -0
- package/templates/frontend/src/app/(dashboard)/planner/page.tsx +287 -0
- package/templates/frontend/src/app/(dashboard)/settings/page.tsx +165 -128
- package/templates/frontend/src/app/(dashboard)/tasks/page.tsx +366 -0
- package/templates/frontend/src/app/auth/github/callback/page.tsx +82 -0
- package/templates/frontend/src/app/landing/page.tsx +21 -0
- package/templates/frontend/src/app/page.tsx +5 -5
- package/templates/frontend/src/app/setup/page.tsx +15 -14
- package/templates/frontend/src/components/auth/github-button.tsx +25 -0
- package/templates/frontend/src/components/dashboard/sidebar.tsx +90 -71
- package/templates/frontend/src/components/ui/alert-dialog.tsx +141 -0
- package/templates/frontend/src/components/ui/button.tsx +56 -52
- package/templates/frontend/src/components/ui/popover.tsx +31 -0
- package/templates/frontend/src/components/ui/select.tsx +160 -0
- package/templates/frontend/src/components/ui/sheet.tsx +140 -0
- package/templates/frontend/src/lib/api/auth.ts +7 -0
- package/templates/frontend/src/lib/api/clients.ts +17 -0
- package/templates/frontend/src/lib/api/invoices.ts +18 -0
- package/templates/frontend/src/lib/api/notifications.ts +27 -0
- package/templates/frontend/src/lib/api/pipeline.ts +18 -0
- package/templates/frontend/src/lib/api/planner.ts +26 -0
- package/templates/frontend/src/lib/api/task-columns.ts +17 -0
- package/templates/frontend/src/lib/api/tasks.ts +21 -0
- package/templates/frontend/src/lib/hooks/use-unread-notifications.ts +23 -0
- package/templates/frontend/src/providers/auth-provider.tsx +7 -1
- package/templates/frontend/src/types/clients.ts +38 -0
- package/templates/frontend/src/types/invoices.ts +51 -0
- package/templates/frontend/src/types/notifications.ts +30 -0
- package/templates/frontend/src/types/pipeline.ts +35 -0
- package/templates/frontend/src/types/planner.ts +49 -0
- package/templates/frontend/src/types/tasks.ts +65 -0
|
@@ -17,14 +17,23 @@ import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
|
|
17
17
|
import { LoginDto } from './dto/login.dto';
|
|
18
18
|
import { RegisterDto } from './dto/register.dto';
|
|
19
19
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
|
20
|
+
import { GithubProfile } from './strategies/github.strategy';
|
|
20
21
|
|
|
21
22
|
interface TokenPair {
|
|
22
23
|
accessToken: string;
|
|
23
24
|
refreshToken: string;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
interface OAuthCode {
|
|
28
|
+
userId: string;
|
|
29
|
+
alreadyExisted: boolean;
|
|
30
|
+
expiresAt: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
@Injectable()
|
|
27
34
|
export class AuthService {
|
|
35
|
+
private readonly oauthCodes = new Map<string, OAuthCode>();
|
|
36
|
+
|
|
28
37
|
constructor(
|
|
29
38
|
private readonly usersService: UsersService,
|
|
30
39
|
private readonly workspacesService: WorkspacesService,
|
|
@@ -34,19 +43,19 @@ export class AuthService {
|
|
|
34
43
|
) {}
|
|
35
44
|
|
|
36
45
|
async register(dto: RegisterDto): Promise<{ message: string }> {
|
|
37
|
-
const
|
|
38
|
-
|
|
46
|
+
const user = await this.usersService.create({
|
|
47
|
+
name: dto.name,
|
|
48
|
+
email: dto.email,
|
|
49
|
+
password: dto.password,
|
|
50
|
+
workspaceId: '',
|
|
51
|
+
});
|
|
52
|
+
const userId = String(user._id);
|
|
39
53
|
|
|
40
54
|
try {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
email: dto.email,
|
|
44
|
-
password: dto.password,
|
|
45
|
-
workspaceId,
|
|
46
|
-
});
|
|
47
|
-
const userId = String(user._id);
|
|
55
|
+
const workspace = await this.workspacesService.create(userId, dto.name);
|
|
56
|
+
const workspaceId = String(workspace._id);
|
|
48
57
|
|
|
49
|
-
await this.
|
|
58
|
+
await this.usersService.updateWorkspaceId(userId, workspaceId);
|
|
50
59
|
|
|
51
60
|
const token = this.generateSecureToken();
|
|
52
61
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
@@ -55,7 +64,7 @@ export class AuthService {
|
|
|
55
64
|
|
|
56
65
|
return { message: 'Verification email sent' };
|
|
57
66
|
} catch (error) {
|
|
58
|
-
await this.
|
|
67
|
+
await this.usersService.delete(userId);
|
|
59
68
|
throw error;
|
|
60
69
|
}
|
|
61
70
|
}
|
|
@@ -192,6 +201,82 @@ export class AuthService {
|
|
|
192
201
|
return { message: 'Password reset successful' };
|
|
193
202
|
}
|
|
194
203
|
|
|
204
|
+
async githubLogin(
|
|
205
|
+
profile: GithubProfile,
|
|
206
|
+
): Promise<{ code: string; alreadyExisted: boolean }> {
|
|
207
|
+
let alreadyExisted = false;
|
|
208
|
+
let user = await this.usersService.findByGithubId(profile.githubId);
|
|
209
|
+
|
|
210
|
+
if (!user) {
|
|
211
|
+
const existingByEmail = await this.usersService.findByEmail(profile.email);
|
|
212
|
+
if (existingByEmail) {
|
|
213
|
+
const existingId = String(existingByEmail._id);
|
|
214
|
+
await this.usersService.linkGithubId(existingId, profile.githubId);
|
|
215
|
+
if (!existingByEmail.emailVerified) {
|
|
216
|
+
await this.usersService.markEmailVerified(existingId);
|
|
217
|
+
}
|
|
218
|
+
user = await this.usersService.findById(existingId);
|
|
219
|
+
alreadyExisted = true;
|
|
220
|
+
} else {
|
|
221
|
+
user = await this.usersService.createGithubUser({
|
|
222
|
+
name: profile.name,
|
|
223
|
+
email: profile.email,
|
|
224
|
+
githubId: profile.githubId,
|
|
225
|
+
workspaceId: '',
|
|
226
|
+
});
|
|
227
|
+
const userId = String(user._id);
|
|
228
|
+
const workspace = await this.workspacesService.create(userId, profile.name);
|
|
229
|
+
const workspaceId = String(workspace._id);
|
|
230
|
+
await this.usersService.updateWorkspaceId(userId, workspaceId);
|
|
231
|
+
user = await this.usersService.findById(userId);
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
alreadyExisted = true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!user) {
|
|
238
|
+
throw new InternalServerErrorException('Error al obtener el usuario.');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const userId = String(user._id);
|
|
242
|
+
await this.usersService.updateLastLoginAt(userId);
|
|
243
|
+
|
|
244
|
+
const code = crypto.randomBytes(32).toString('hex');
|
|
245
|
+
this.oauthCodes.set(code, {
|
|
246
|
+
userId,
|
|
247
|
+
alreadyExisted,
|
|
248
|
+
expiresAt: Date.now() + 30_000,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return { code, alreadyExisted };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async exchangeGithubCode(
|
|
255
|
+
code: string,
|
|
256
|
+
): Promise<{ alreadyExisted: boolean } & TokenPair> {
|
|
257
|
+
const entry = this.oauthCodes.get(code);
|
|
258
|
+
if (!entry || entry.expiresAt < Date.now()) {
|
|
259
|
+
this.oauthCodes.delete(code);
|
|
260
|
+
throw new UnauthorizedException('Código inválido o expirado.');
|
|
261
|
+
}
|
|
262
|
+
this.oauthCodes.delete(code);
|
|
263
|
+
|
|
264
|
+
const user = await this.usersService.findById(entry.userId);
|
|
265
|
+
if (!user) {
|
|
266
|
+
throw new UnauthorizedException('Usuario no encontrado.');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const tokens = await this.generateTokens(
|
|
270
|
+
entry.userId,
|
|
271
|
+
user.email,
|
|
272
|
+
user.workspaceId,
|
|
273
|
+
user.role,
|
|
274
|
+
);
|
|
275
|
+
await this.usersService.updateRefreshToken(entry.userId, tokens.refreshToken);
|
|
276
|
+
|
|
277
|
+
return { alreadyExisted: entry.alreadyExisted, ...tokens };
|
|
278
|
+
}
|
|
279
|
+
|
|
195
280
|
async getMe(userId: string): Promise<UserDocument> {
|
|
196
281
|
const user = await this.usersService.findById(userId);
|
|
197
282
|
if (!user) {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import { PassportStrategy } from '@nestjs/passport';
|
|
4
|
+
import { Strategy, Profile } from 'passport-github2';
|
|
5
|
+
|
|
6
|
+
export interface GithubProfile {
|
|
7
|
+
githubId: string;
|
|
8
|
+
name: string;
|
|
9
|
+
email: string;
|
|
10
|
+
avatarUrl: string | null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Injectable()
|
|
14
|
+
export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
|
|
15
|
+
constructor(configService: ConfigService) {
|
|
16
|
+
const opts = {
|
|
17
|
+
clientID: configService.getOrThrow<string>('GITHUB_CLIENT_ID'),
|
|
18
|
+
clientSecret: configService.getOrThrow<string>('GITHUB_CLIENT_SECRET'),
|
|
19
|
+
callbackURL: configService.getOrThrow<string>('GITHUB_CALLBACK_URL'),
|
|
20
|
+
scope: ['user:email'],
|
|
21
|
+
};
|
|
22
|
+
super(opts);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
validate(
|
|
26
|
+
_accessToken: string,
|
|
27
|
+
_refreshToken: string,
|
|
28
|
+
profile: Profile,
|
|
29
|
+
done: (err: Error | null, user: GithubProfile | false) => void,
|
|
30
|
+
): void {
|
|
31
|
+
const email =
|
|
32
|
+
profile.emails?.[0]?.value ?? null;
|
|
33
|
+
|
|
34
|
+
if (!email) {
|
|
35
|
+
done(new Error('No public email on GitHub account'), false);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
done(null, {
|
|
40
|
+
githubId: profile.id,
|
|
41
|
+
name: profile.displayName || profile.username || email.split('@')[0],
|
|
42
|
+
email,
|
|
43
|
+
avatarUrl: profile.photos?.[0]?.value ?? null,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Delete,
|
|
5
|
+
Get,
|
|
6
|
+
HttpCode,
|
|
7
|
+
HttpStatus,
|
|
8
|
+
Param,
|
|
9
|
+
Patch,
|
|
10
|
+
Post,
|
|
11
|
+
Query,
|
|
12
|
+
UseGuards,
|
|
13
|
+
} from '@nestjs/common';
|
|
14
|
+
import {
|
|
15
|
+
ApiBearerAuth,
|
|
16
|
+
ApiOperation,
|
|
17
|
+
ApiResponse,
|
|
18
|
+
ApiTags,
|
|
19
|
+
} from '@nestjs/swagger';
|
|
20
|
+
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|
21
|
+
import type { TokenPayload } from '../../common/decorators/current-user.decorator';
|
|
22
|
+
import { WorkspaceId } from '../../common/decorators/workspace-id.decorator';
|
|
23
|
+
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
24
|
+
import { ClientsService } from './clients.service';
|
|
25
|
+
import { CreateClientDto } from './dto/create-client.dto';
|
|
26
|
+
import { QueryClientDto } from './dto/query-client.dto';
|
|
27
|
+
import { UpdateClientDto } from './dto/update-client.dto';
|
|
28
|
+
|
|
29
|
+
@ApiTags('Clients')
|
|
30
|
+
@ApiBearerAuth()
|
|
31
|
+
@UseGuards(JwtAuthGuard)
|
|
32
|
+
@Controller('clients')
|
|
33
|
+
export class ClientsController {
|
|
34
|
+
constructor(private readonly clientsService: ClientsService) {}
|
|
35
|
+
|
|
36
|
+
@Get()
|
|
37
|
+
@ApiOperation({ summary: 'Listar clientes del workspace' })
|
|
38
|
+
@ApiResponse({ status: 200, description: 'Lista paginada de clientes' })
|
|
39
|
+
findAll(
|
|
40
|
+
@WorkspaceId() workspaceId: string,
|
|
41
|
+
@Query() query: QueryClientDto,
|
|
42
|
+
) {
|
|
43
|
+
return this.clientsService.findAll(workspaceId, query);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Get(':id')
|
|
47
|
+
@ApiOperation({ summary: 'Obtener un cliente por ID' })
|
|
48
|
+
@ApiResponse({ status: 200, description: 'Cliente encontrado' })
|
|
49
|
+
@ApiResponse({ status: 404, description: 'Cliente no encontrado' })
|
|
50
|
+
findOne(
|
|
51
|
+
@WorkspaceId() workspaceId: string,
|
|
52
|
+
@Param('id') id: string,
|
|
53
|
+
) {
|
|
54
|
+
return this.clientsService.findOne(workspaceId, id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Post()
|
|
58
|
+
@ApiOperation({ summary: 'Crear un nuevo cliente' })
|
|
59
|
+
@ApiResponse({ status: 201, description: 'Cliente creado' })
|
|
60
|
+
create(
|
|
61
|
+
@WorkspaceId() workspaceId: string,
|
|
62
|
+
@CurrentUser() user: TokenPayload,
|
|
63
|
+
@Body() dto: CreateClientDto,
|
|
64
|
+
) {
|
|
65
|
+
return this.clientsService.create(workspaceId, user.userId, dto);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Patch(':id')
|
|
69
|
+
@ApiOperation({ summary: 'Actualizar un cliente' })
|
|
70
|
+
@ApiResponse({ status: 200, description: 'Cliente actualizado' })
|
|
71
|
+
@ApiResponse({ status: 404, description: 'Cliente no encontrado' })
|
|
72
|
+
update(
|
|
73
|
+
@WorkspaceId() workspaceId: string,
|
|
74
|
+
@Param('id') id: string,
|
|
75
|
+
@Body() dto: UpdateClientDto,
|
|
76
|
+
) {
|
|
77
|
+
return this.clientsService.update(workspaceId, id, dto);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Delete(':id')
|
|
81
|
+
@HttpCode(HttpStatus.NO_CONTENT)
|
|
82
|
+
@ApiOperation({ summary: 'Eliminar un cliente (soft delete)' })
|
|
83
|
+
@ApiResponse({ status: 204, description: 'Cliente eliminado' })
|
|
84
|
+
@ApiResponse({ status: 404, description: 'Cliente no encontrado' })
|
|
85
|
+
remove(
|
|
86
|
+
@WorkspaceId() workspaceId: string,
|
|
87
|
+
@Param('id') id: string,
|
|
88
|
+
) {
|
|
89
|
+
return this.clientsService.remove(workspaceId, id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
3
|
+
import { ClientsController } from './clients.controller';
|
|
4
|
+
import { ClientsRepository } from './clients.repository';
|
|
5
|
+
import { ClientsService } from './clients.service';
|
|
6
|
+
import { Client, ClientSchema } from './schemas/client.schema';
|
|
7
|
+
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [
|
|
10
|
+
MongooseModule.forFeature([{ name: Client.name, schema: ClientSchema }]),
|
|
11
|
+
],
|
|
12
|
+
controllers: [ClientsController],
|
|
13
|
+
providers: [ClientsService, ClientsRepository],
|
|
14
|
+
exports: [ClientsService],
|
|
15
|
+
})
|
|
16
|
+
export class ClientsModule {}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { InjectModel } from '@nestjs/mongoose';
|
|
3
|
+
import { Model } from 'mongoose';
|
|
4
|
+
import { BaseRepository } from '../../common/base/base.repository';
|
|
5
|
+
import { Client, ClientDocument } from './schemas/client.schema';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class ClientsRepository extends BaseRepository<ClientDocument> {
|
|
9
|
+
constructor(
|
|
10
|
+
@InjectModel(Client.name) private readonly clientModel: Model<ClientDocument>,
|
|
11
|
+
) {
|
|
12
|
+
super(clientModel);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { ClientsRepository } from './clients.repository';
|
|
3
|
+
import { CreateClientDto } from './dto/create-client.dto';
|
|
4
|
+
import { QueryClientDto } from './dto/query-client.dto';
|
|
5
|
+
import { UpdateClientDto } from './dto/update-client.dto';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class ClientsService {
|
|
9
|
+
constructor(private readonly clientsRepository: ClientsRepository) {}
|
|
10
|
+
|
|
11
|
+
findAll(workspaceId: string, query: QueryClientDto) {
|
|
12
|
+
const { page, limit, search, status } = query;
|
|
13
|
+
const filter: Record<string, unknown> = {};
|
|
14
|
+
|
|
15
|
+
if (status) filter.status = status;
|
|
16
|
+
if (search) {
|
|
17
|
+
filter.$or = [
|
|
18
|
+
{ name: { $regex: search, $options: 'i' } },
|
|
19
|
+
{ email: { $regex: search, $options: 'i' } },
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return this.clientsRepository.paginate(workspaceId, { page, limit, filter, sort: { createdAt: -1 } });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async findOne(workspaceId: string, id: string) {
|
|
27
|
+
const client = await this.clientsRepository.findById(workspaceId, id);
|
|
28
|
+
if (!client) throw new NotFoundException('Cliente no encontrado');
|
|
29
|
+
return client;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
create(workspaceId: string, userId: string, dto: CreateClientDto) {
|
|
33
|
+
return this.clientsRepository.create({
|
|
34
|
+
...dto,
|
|
35
|
+
workspaceId: workspaceId as unknown as import('mongoose').Types.ObjectId,
|
|
36
|
+
createdBy: userId as unknown as import('mongoose').Types.ObjectId,
|
|
37
|
+
deletedAt: null,
|
|
38
|
+
} as never);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async update(workspaceId: string, id: string, dto: UpdateClientDto) {
|
|
42
|
+
await this.findOne(workspaceId, id);
|
|
43
|
+
const client = await this.clientsRepository.update(workspaceId, id, dto);
|
|
44
|
+
if (!client) throw new NotFoundException('Cliente no encontrado');
|
|
45
|
+
return client;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async remove(workspaceId: string, id: string) {
|
|
49
|
+
await this.findOne(workspaceId, id);
|
|
50
|
+
await this.clientsRepository.softDelete(workspaceId, id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import {
|
|
3
|
+
IsEmail,
|
|
4
|
+
IsEnum,
|
|
5
|
+
IsOptional,
|
|
6
|
+
IsString,
|
|
7
|
+
MaxLength,
|
|
8
|
+
} from 'class-validator';
|
|
9
|
+
|
|
10
|
+
export class CreateClientDto {
|
|
11
|
+
@ApiProperty({ example: 'Empresa SA', maxLength: 200 })
|
|
12
|
+
@IsString({ message: 'El nombre debe ser un texto' })
|
|
13
|
+
@MaxLength(200, { message: 'El nombre no puede superar los 200 caracteres' })
|
|
14
|
+
name!: string;
|
|
15
|
+
|
|
16
|
+
@ApiPropertyOptional({ example: 'empresa@ejemplo.com' })
|
|
17
|
+
@IsOptional()
|
|
18
|
+
@IsEmail({}, { message: 'El email no tiene un formato válido' })
|
|
19
|
+
email?: string;
|
|
20
|
+
|
|
21
|
+
@ApiPropertyOptional({ example: '+54 11 1234-5678' })
|
|
22
|
+
@IsOptional()
|
|
23
|
+
@IsString({ message: 'El teléfono debe ser un texto' })
|
|
24
|
+
phone?: string;
|
|
25
|
+
|
|
26
|
+
@ApiPropertyOptional({ example: 'Av. Corrientes 1234, CABA' })
|
|
27
|
+
@IsOptional()
|
|
28
|
+
@IsString({ message: 'La dirección debe ser un texto' })
|
|
29
|
+
address?: string;
|
|
30
|
+
|
|
31
|
+
@ApiPropertyOptional({ example: 'Cliente VIP', })
|
|
32
|
+
@IsOptional()
|
|
33
|
+
@IsString({ message: 'Las notas deben ser un texto' })
|
|
34
|
+
notes?: string;
|
|
35
|
+
|
|
36
|
+
@ApiPropertyOptional({ enum: ['active', 'archived'], default: 'active' })
|
|
37
|
+
@IsOptional()
|
|
38
|
+
@IsEnum(['active', 'archived'], { message: 'El estado debe ser "active" o "archived"' })
|
|
39
|
+
status?: 'active' | 'archived';
|
|
40
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
4
|
+
|
|
5
|
+
export class QueryClientDto {
|
|
6
|
+
@ApiPropertyOptional({ description: 'Búsqueda por nombre o email' })
|
|
7
|
+
@IsOptional()
|
|
8
|
+
@IsString({ message: 'La búsqueda debe ser un texto' })
|
|
9
|
+
search?: string;
|
|
10
|
+
|
|
11
|
+
@ApiPropertyOptional({ enum: ['active', 'archived'] })
|
|
12
|
+
@IsOptional()
|
|
13
|
+
@IsEnum(['active', 'archived'], { message: 'El estado debe ser "active" o "archived"' })
|
|
14
|
+
status?: 'active' | 'archived';
|
|
15
|
+
|
|
16
|
+
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
|
17
|
+
@IsOptional()
|
|
18
|
+
@Type(() => Number)
|
|
19
|
+
@IsInt({ message: 'La página debe ser un número entero' })
|
|
20
|
+
@Min(1, { message: 'La página mínima es 1' })
|
|
21
|
+
page?: number = 1;
|
|
22
|
+
|
|
23
|
+
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
|
24
|
+
@IsOptional()
|
|
25
|
+
@Type(() => Number)
|
|
26
|
+
@IsInt({ message: 'El límite debe ser un número entero' })
|
|
27
|
+
@Min(1, { message: 'El límite mínimo es 1' })
|
|
28
|
+
@Max(100, { message: 'El límite máximo es 100' })
|
|
29
|
+
limit?: number = 20;
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
|
2
|
+
import { HydratedDocument } from 'mongoose';
|
|
3
|
+
import { BaseSchema } from '../../../common/base/base.schema';
|
|
4
|
+
|
|
5
|
+
export type ClientDocument = HydratedDocument<Client>;
|
|
6
|
+
|
|
7
|
+
@Schema({ collection: 'clients', timestamps: true })
|
|
8
|
+
export class Client extends BaseSchema {
|
|
9
|
+
@Prop({ required: true, trim: true, maxlength: 200 })
|
|
10
|
+
name!: string;
|
|
11
|
+
|
|
12
|
+
@Prop({ trim: true, lowercase: true })
|
|
13
|
+
email!: string;
|
|
14
|
+
|
|
15
|
+
@Prop({ trim: true })
|
|
16
|
+
phone!: string;
|
|
17
|
+
|
|
18
|
+
@Prop({ trim: true })
|
|
19
|
+
address!: string;
|
|
20
|
+
|
|
21
|
+
@Prop({ type: String, enum: ['active', 'archived'], default: 'active' })
|
|
22
|
+
status!: 'active' | 'archived';
|
|
23
|
+
|
|
24
|
+
@Prop({ trim: true })
|
|
25
|
+
notes!: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const ClientSchema = SchemaFactory.createForClass(Client);
|
|
29
|
+
|
|
30
|
+
ClientSchema.index({ workspaceId: 1 });
|
|
31
|
+
ClientSchema.index({ workspaceId: 1, status: 1 });
|
|
32
|
+
ClientSchema.index({ workspaceId: 1, createdAt: -1 });
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import {
|
|
4
|
+
IsArray,
|
|
5
|
+
IsDateString,
|
|
6
|
+
IsEnum,
|
|
7
|
+
IsNumber,
|
|
8
|
+
IsOptional,
|
|
9
|
+
IsString,
|
|
10
|
+
Min,
|
|
11
|
+
ValidateNested,
|
|
12
|
+
} from 'class-validator';
|
|
13
|
+
import { InvoiceItemDto } from './invoice-item.dto';
|
|
14
|
+
|
|
15
|
+
export class CreateInvoiceDto {
|
|
16
|
+
@ApiPropertyOptional({ enum: ['income', 'expense'], default: 'income' })
|
|
17
|
+
@IsOptional()
|
|
18
|
+
@IsEnum(['income', 'expense'], { message: 'El tipo debe ser "income" o "expense"' })
|
|
19
|
+
type?: 'income' | 'expense';
|
|
20
|
+
|
|
21
|
+
@ApiPropertyOptional({ example: 'FAC-001' })
|
|
22
|
+
@IsOptional()
|
|
23
|
+
@IsString({ message: 'El número debe ser un texto' })
|
|
24
|
+
number?: string;
|
|
25
|
+
|
|
26
|
+
@ApiPropertyOptional({ description: 'ID del cliente' })
|
|
27
|
+
@IsOptional()
|
|
28
|
+
@IsString({ message: 'El clientId debe ser un texto' })
|
|
29
|
+
clientId?: string;
|
|
30
|
+
|
|
31
|
+
@ApiPropertyOptional({
|
|
32
|
+
enum: ['draft', 'pending', 'paid', 'overdue', 'cancelled'],
|
|
33
|
+
default: 'draft',
|
|
34
|
+
})
|
|
35
|
+
@IsOptional()
|
|
36
|
+
@IsEnum(['draft', 'pending', 'paid', 'overdue', 'cancelled'], {
|
|
37
|
+
message: 'El estado no es válido',
|
|
38
|
+
})
|
|
39
|
+
status?: 'draft' | 'pending' | 'paid' | 'overdue' | 'cancelled';
|
|
40
|
+
|
|
41
|
+
@ApiPropertyOptional({ example: '2026-01-15' })
|
|
42
|
+
@IsOptional()
|
|
43
|
+
@IsDateString({}, { message: 'La fecha de emisión debe ser una fecha válida' })
|
|
44
|
+
issueDate?: string;
|
|
45
|
+
|
|
46
|
+
@ApiPropertyOptional({ example: '2026-02-15' })
|
|
47
|
+
@IsOptional()
|
|
48
|
+
@IsDateString({}, { message: 'La fecha de vencimiento debe ser una fecha válida' })
|
|
49
|
+
dueDate?: string;
|
|
50
|
+
|
|
51
|
+
@ApiPropertyOptional({ type: [InvoiceItemDto] })
|
|
52
|
+
@IsOptional()
|
|
53
|
+
@IsArray({ message: 'Los ítems deben ser un array' })
|
|
54
|
+
@ValidateNested({ each: true })
|
|
55
|
+
@Type(() => InvoiceItemDto)
|
|
56
|
+
items?: InvoiceItemDto[];
|
|
57
|
+
|
|
58
|
+
@ApiPropertyOptional({ example: 21, minimum: 0 })
|
|
59
|
+
@IsOptional()
|
|
60
|
+
@IsNumber({}, { message: 'La tasa de impuesto debe ser un número' })
|
|
61
|
+
@Min(0, { message: 'La tasa de impuesto no puede ser negativa' })
|
|
62
|
+
taxRate?: number;
|
|
63
|
+
|
|
64
|
+
@ApiPropertyOptional({ example: 'USD' })
|
|
65
|
+
@IsOptional()
|
|
66
|
+
@IsString({ message: 'La moneda debe ser un texto' })
|
|
67
|
+
currency?: string;
|
|
68
|
+
|
|
69
|
+
@ApiPropertyOptional()
|
|
70
|
+
@IsOptional()
|
|
71
|
+
@IsString({ message: 'Las notas deben ser un texto' })
|
|
72
|
+
notes?: string;
|
|
73
|
+
|
|
74
|
+
@ApiPropertyOptional({ example: 1000, minimum: 0 })
|
|
75
|
+
@IsOptional()
|
|
76
|
+
@IsNumber({}, { message: 'El total debe ser un número' })
|
|
77
|
+
@Min(0, { message: 'El total no puede ser negativo' })
|
|
78
|
+
total?: number;
|
|
79
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { IsNumber, IsString, Min } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class InvoiceItemDto {
|
|
5
|
+
@ApiProperty({ example: 'Consultoría mensual' })
|
|
6
|
+
@IsString({ message: 'La descripción debe ser un texto' })
|
|
7
|
+
description!: string;
|
|
8
|
+
|
|
9
|
+
@ApiProperty({ example: 1, minimum: 0 })
|
|
10
|
+
@IsNumber({}, { message: 'La cantidad debe ser un número' })
|
|
11
|
+
@Min(0, { message: 'La cantidad no puede ser negativa' })
|
|
12
|
+
quantity!: number;
|
|
13
|
+
|
|
14
|
+
@ApiProperty({ example: 1000, minimum: 0 })
|
|
15
|
+
@IsNumber({}, { message: 'El precio unitario debe ser un número' })
|
|
16
|
+
@Min(0, { message: 'El precio unitario no puede ser negativo' })
|
|
17
|
+
unitPrice!: number;
|
|
18
|
+
|
|
19
|
+
@ApiProperty({ example: 1000, minimum: 0 })
|
|
20
|
+
@IsNumber({}, { message: 'El monto debe ser un número' })
|
|
21
|
+
@Min(0, { message: 'El monto no puede ser negativo' })
|
|
22
|
+
amount!: number;
|
|
23
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
4
|
+
|
|
5
|
+
export class QueryInvoiceDto {
|
|
6
|
+
@ApiPropertyOptional()
|
|
7
|
+
@IsOptional()
|
|
8
|
+
@IsString({ message: 'La búsqueda debe ser un texto' })
|
|
9
|
+
search?: string;
|
|
10
|
+
|
|
11
|
+
@ApiPropertyOptional({ enum: ['draft', 'pending', 'paid', 'overdue', 'cancelled'] })
|
|
12
|
+
@IsOptional()
|
|
13
|
+
@IsEnum(['draft', 'pending', 'paid', 'overdue', 'cancelled'], { message: 'El estado no es válido' })
|
|
14
|
+
status?: 'draft' | 'pending' | 'paid' | 'overdue' | 'cancelled';
|
|
15
|
+
|
|
16
|
+
@ApiPropertyOptional({ enum: ['income', 'expense'] })
|
|
17
|
+
@IsOptional()
|
|
18
|
+
@IsEnum(['income', 'expense'], { message: 'El tipo debe ser "income" o "expense"' })
|
|
19
|
+
type?: 'income' | 'expense';
|
|
20
|
+
|
|
21
|
+
@ApiPropertyOptional()
|
|
22
|
+
@IsOptional()
|
|
23
|
+
@IsString({ message: 'El clientId debe ser un texto' })
|
|
24
|
+
clientId?: string;
|
|
25
|
+
|
|
26
|
+
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
|
27
|
+
@IsOptional()
|
|
28
|
+
@Type(() => Number)
|
|
29
|
+
@IsInt({ message: 'La página debe ser un número entero' })
|
|
30
|
+
@Min(1, { message: 'La página mínima es 1' })
|
|
31
|
+
page?: number = 1;
|
|
32
|
+
|
|
33
|
+
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
|
34
|
+
@IsOptional()
|
|
35
|
+
@Type(() => Number)
|
|
36
|
+
@IsInt({ message: 'El límite debe ser un número entero' })
|
|
37
|
+
@Min(1, { message: 'El límite mínimo es 1' })
|
|
38
|
+
@Max(100, { message: 'El límite máximo es 100' })
|
|
39
|
+
limit?: number = 20;
|
|
40
|
+
}
|