ar-saas 0.4.2 → 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 +2 -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/auth/strategies/jwt.strategy.ts +1 -1
- 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
|
@@ -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 { CreateInvoiceDto } from './dto/create-invoice.dto';
|
|
25
|
+
import { QueryInvoiceDto } from './dto/query-invoice.dto';
|
|
26
|
+
import { UpdateInvoiceDto } from './dto/update-invoice.dto';
|
|
27
|
+
import { InvoicesService } from './invoices.service';
|
|
28
|
+
|
|
29
|
+
@ApiTags('Invoices')
|
|
30
|
+
@ApiBearerAuth()
|
|
31
|
+
@UseGuards(JwtAuthGuard)
|
|
32
|
+
@Controller('invoices')
|
|
33
|
+
export class InvoicesController {
|
|
34
|
+
constructor(private readonly invoicesService: InvoicesService) {}
|
|
35
|
+
|
|
36
|
+
@Get()
|
|
37
|
+
@ApiOperation({ summary: 'Listar facturas del workspace' })
|
|
38
|
+
@ApiResponse({ status: 200, description: 'Lista paginada de facturas' })
|
|
39
|
+
findAll(
|
|
40
|
+
@WorkspaceId() workspaceId: string,
|
|
41
|
+
@Query() query: QueryInvoiceDto,
|
|
42
|
+
) {
|
|
43
|
+
return this.invoicesService.findAll(workspaceId, query);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Get(':id')
|
|
47
|
+
@ApiOperation({ summary: 'Obtener una factura por ID' })
|
|
48
|
+
@ApiResponse({ status: 200, description: 'Factura encontrada' })
|
|
49
|
+
@ApiResponse({ status: 404, description: 'Factura no encontrada' })
|
|
50
|
+
findOne(
|
|
51
|
+
@WorkspaceId() workspaceId: string,
|
|
52
|
+
@Param('id') id: string,
|
|
53
|
+
) {
|
|
54
|
+
return this.invoicesService.findOne(workspaceId, id);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Post()
|
|
58
|
+
@ApiOperation({ summary: 'Crear una nueva factura' })
|
|
59
|
+
@ApiResponse({ status: 201, description: 'Factura creada' })
|
|
60
|
+
create(
|
|
61
|
+
@WorkspaceId() workspaceId: string,
|
|
62
|
+
@CurrentUser() user: TokenPayload,
|
|
63
|
+
@Body() dto: CreateInvoiceDto,
|
|
64
|
+
) {
|
|
65
|
+
return this.invoicesService.create(workspaceId, user.userId, dto);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@Patch(':id')
|
|
69
|
+
@ApiOperation({ summary: 'Actualizar una factura' })
|
|
70
|
+
@ApiResponse({ status: 200, description: 'Factura actualizada' })
|
|
71
|
+
@ApiResponse({ status: 404, description: 'Factura no encontrada' })
|
|
72
|
+
update(
|
|
73
|
+
@WorkspaceId() workspaceId: string,
|
|
74
|
+
@Param('id') id: string,
|
|
75
|
+
@Body() dto: UpdateInvoiceDto,
|
|
76
|
+
) {
|
|
77
|
+
return this.invoicesService.update(workspaceId, id, dto);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
@Delete(':id')
|
|
81
|
+
@HttpCode(HttpStatus.NO_CONTENT)
|
|
82
|
+
@ApiOperation({ summary: 'Eliminar una factura (soft delete)' })
|
|
83
|
+
@ApiResponse({ status: 204, description: 'Factura eliminada' })
|
|
84
|
+
@ApiResponse({ status: 404, description: 'Factura no encontrada' })
|
|
85
|
+
remove(
|
|
86
|
+
@WorkspaceId() workspaceId: string,
|
|
87
|
+
@Param('id') id: string,
|
|
88
|
+
) {
|
|
89
|
+
return this.invoicesService.remove(workspaceId, id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
3
|
+
import { ClientsModule } from '../clients/clients.module';
|
|
4
|
+
import { Invoice, InvoiceSchema } from './schemas/invoice.schema';
|
|
5
|
+
import { InvoicesController } from './invoices.controller';
|
|
6
|
+
import { InvoicesRepository } from './invoices.repository';
|
|
7
|
+
import { InvoicesService } from './invoices.service';
|
|
8
|
+
|
|
9
|
+
@Module({
|
|
10
|
+
imports: [
|
|
11
|
+
ClientsModule,
|
|
12
|
+
MongooseModule.forFeature([{ name: Invoice.name, schema: InvoiceSchema }]),
|
|
13
|
+
],
|
|
14
|
+
controllers: [InvoicesController],
|
|
15
|
+
providers: [InvoicesService, InvoicesRepository],
|
|
16
|
+
exports: [InvoicesService],
|
|
17
|
+
})
|
|
18
|
+
export class InvoicesModule {}
|
|
@@ -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 { Invoice, InvoiceDocument } from './schemas/invoice.schema';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class InvoicesRepository extends BaseRepository<InvoiceDocument> {
|
|
9
|
+
constructor(
|
|
10
|
+
@InjectModel(Invoice.name) private readonly invoiceModel: Model<InvoiceDocument>,
|
|
11
|
+
) {
|
|
12
|
+
super(invoiceModel);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { ClientsService } from '../clients/clients.service';
|
|
3
|
+
import { CreateInvoiceDto } from './dto/create-invoice.dto';
|
|
4
|
+
import { QueryInvoiceDto } from './dto/query-invoice.dto';
|
|
5
|
+
import { UpdateInvoiceDto } from './dto/update-invoice.dto';
|
|
6
|
+
import { InvoiceItem } from './schemas/invoice.schema';
|
|
7
|
+
import { InvoicesRepository } from './invoices.repository';
|
|
8
|
+
|
|
9
|
+
@Injectable()
|
|
10
|
+
export class InvoicesService {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly invoicesRepository: InvoicesRepository,
|
|
13
|
+
private readonly clientsService: ClientsService,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
findAll(workspaceId: string, query: QueryInvoiceDto) {
|
|
17
|
+
const { page, limit, search, status, type, clientId } = query;
|
|
18
|
+
const filter: Record<string, unknown> = {};
|
|
19
|
+
|
|
20
|
+
if (status) filter.status = status;
|
|
21
|
+
if (type) filter.type = type;
|
|
22
|
+
if (clientId) filter.clientId = clientId;
|
|
23
|
+
if (search) {
|
|
24
|
+
filter.$or = [
|
|
25
|
+
{ number: { $regex: search, $options: 'i' } },
|
|
26
|
+
{ notes: { $regex: search, $options: 'i' } },
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return this.invoicesRepository.paginate(workspaceId, {
|
|
31
|
+
page,
|
|
32
|
+
limit,
|
|
33
|
+
filter,
|
|
34
|
+
sort: { createdAt: -1 },
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async findOne(workspaceId: string, id: string) {
|
|
39
|
+
const invoice = await this.invoicesRepository.findById(workspaceId, id);
|
|
40
|
+
if (!invoice) throw new NotFoundException('Factura no encontrada');
|
|
41
|
+
return invoice;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async create(workspaceId: string, userId: string, dto: CreateInvoiceDto) {
|
|
45
|
+
if (dto.clientId) {
|
|
46
|
+
await this.clientsService.findOne(workspaceId, dto.clientId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const items = dto.items ?? [];
|
|
50
|
+
const totals = items.length > 0
|
|
51
|
+
? this.computeTotals(items, dto.taxRate ?? 0)
|
|
52
|
+
: { subtotal: dto.total ?? 0, taxAmount: 0, total: dto.total ?? 0 };
|
|
53
|
+
|
|
54
|
+
return this.invoicesRepository.create({
|
|
55
|
+
...dto,
|
|
56
|
+
...totals,
|
|
57
|
+
issueDate: dto.issueDate ? new Date(dto.issueDate) : new Date(),
|
|
58
|
+
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
|
|
59
|
+
workspaceId: workspaceId as unknown as import('mongoose').Types.ObjectId,
|
|
60
|
+
createdBy: userId as unknown as import('mongoose').Types.ObjectId,
|
|
61
|
+
deletedAt: null,
|
|
62
|
+
} as never);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async update(workspaceId: string, id: string, dto: UpdateInvoiceDto) {
|
|
66
|
+
const current = await this.findOne(workspaceId, id);
|
|
67
|
+
const clientId = dto.clientId ?? (current as unknown as { clientId: string }).clientId;
|
|
68
|
+
|
|
69
|
+
if (clientId) {
|
|
70
|
+
await this.clientsService.findOne(workspaceId, clientId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const items = dto.items ?? (current as unknown as { items: InvoiceItem[] }).items ?? [];
|
|
74
|
+
const taxRate = dto.taxRate ?? (current as unknown as { taxRate: number }).taxRate ?? 0;
|
|
75
|
+
const extra: Record<string, unknown> = {};
|
|
76
|
+
|
|
77
|
+
if (items.length > 0) {
|
|
78
|
+
Object.assign(extra, this.computeTotals(items, taxRate));
|
|
79
|
+
} else if (dto.total !== undefined) {
|
|
80
|
+
extra.total = dto.total;
|
|
81
|
+
extra.subtotal = dto.total;
|
|
82
|
+
extra.taxAmount = 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const invoice = await this.invoicesRepository.update(workspaceId, id, { ...dto, ...extra } as never);
|
|
86
|
+
if (!invoice) throw new NotFoundException('Factura no encontrada');
|
|
87
|
+
return invoice;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async remove(workspaceId: string, id: string) {
|
|
91
|
+
await this.findOne(workspaceId, id);
|
|
92
|
+
await this.invoicesRepository.softDelete(workspaceId, id);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private computeTotals(
|
|
96
|
+
items: { amount: number }[],
|
|
97
|
+
taxRate: number,
|
|
98
|
+
): { subtotal: number; taxAmount: number; total: number } {
|
|
99
|
+
const subtotal = items.reduce((sum, item) => sum + item.amount, 0);
|
|
100
|
+
const taxAmount = Math.round(subtotal * (taxRate / 100) * 100) / 100;
|
|
101
|
+
const total = subtotal + taxAmount;
|
|
102
|
+
return { subtotal, taxAmount, total };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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 InvoiceDocument = HydratedDocument<Invoice>;
|
|
6
|
+
|
|
7
|
+
export interface InvoiceItem {
|
|
8
|
+
description: string;
|
|
9
|
+
quantity: number;
|
|
10
|
+
unitPrice: number;
|
|
11
|
+
amount: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
@Schema({ collection: 'invoices', timestamps: true })
|
|
15
|
+
export class Invoice extends BaseSchema {
|
|
16
|
+
@Prop({ type: String, enum: ['income', 'expense'], default: 'income' })
|
|
17
|
+
type!: 'income' | 'expense';
|
|
18
|
+
|
|
19
|
+
@Prop({ trim: true })
|
|
20
|
+
number!: string;
|
|
21
|
+
|
|
22
|
+
@Prop()
|
|
23
|
+
clientId!: string;
|
|
24
|
+
|
|
25
|
+
@Prop({
|
|
26
|
+
type: String,
|
|
27
|
+
enum: ['draft', 'pending', 'paid', 'overdue', 'cancelled'],
|
|
28
|
+
default: 'draft',
|
|
29
|
+
})
|
|
30
|
+
status!: 'draft' | 'pending' | 'paid' | 'overdue' | 'cancelled';
|
|
31
|
+
|
|
32
|
+
@Prop({ type: Date, required: true, default: Date.now })
|
|
33
|
+
issueDate!: Date;
|
|
34
|
+
|
|
35
|
+
@Prop({ type: Date })
|
|
36
|
+
dueDate!: Date;
|
|
37
|
+
|
|
38
|
+
@Prop({
|
|
39
|
+
type: [
|
|
40
|
+
{
|
|
41
|
+
description: { type: String, required: true },
|
|
42
|
+
quantity: { type: Number, required: true, default: 1 },
|
|
43
|
+
unitPrice: { type: Number, required: true },
|
|
44
|
+
amount: { type: Number, required: true },
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
default: [],
|
|
48
|
+
})
|
|
49
|
+
items!: InvoiceItem[];
|
|
50
|
+
|
|
51
|
+
@Prop({ default: 0 })
|
|
52
|
+
subtotal!: number;
|
|
53
|
+
|
|
54
|
+
@Prop({ default: 0 })
|
|
55
|
+
taxRate!: number;
|
|
56
|
+
|
|
57
|
+
@Prop({ default: 0 })
|
|
58
|
+
taxAmount!: number;
|
|
59
|
+
|
|
60
|
+
@Prop({ default: 0 })
|
|
61
|
+
total!: number;
|
|
62
|
+
|
|
63
|
+
@Prop({ default: 'USD', trim: true, uppercase: true })
|
|
64
|
+
currency!: string;
|
|
65
|
+
|
|
66
|
+
@Prop({ trim: true })
|
|
67
|
+
notes!: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const InvoiceSchema = SchemaFactory.createForClass(Invoice);
|
|
71
|
+
|
|
72
|
+
InvoiceSchema.index({ workspaceId: 1, status: 1 });
|
|
73
|
+
InvoiceSchema.index({ workspaceId: 1, type: 1 });
|
|
74
|
+
InvoiceSchema.index({ workspaceId: 1, clientId: 1 });
|
|
75
|
+
InvoiceSchema.index({ workspaceId: 1, createdAt: -1 });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class CreateNotificationDto {
|
|
5
|
+
@ApiProperty({ description: 'ID del usuario destinatario' })
|
|
6
|
+
@IsString({ message: 'El userId debe ser un texto' })
|
|
7
|
+
userId!: string;
|
|
8
|
+
|
|
9
|
+
@ApiProperty({ example: 'Nueva factura recibida', maxLength: 120 })
|
|
10
|
+
@IsString({ message: 'El título debe ser un texto' })
|
|
11
|
+
@MaxLength(120, { message: 'El título no puede superar los 120 caracteres' })
|
|
12
|
+
title!: string;
|
|
13
|
+
|
|
14
|
+
@ApiProperty({ example: 'Se recibió una factura por $1200', maxLength: 500 })
|
|
15
|
+
@IsString({ message: 'El mensaje debe ser un texto' })
|
|
16
|
+
@MaxLength(500, { message: 'El mensaje no puede superar los 500 caracteres' })
|
|
17
|
+
message!: string;
|
|
18
|
+
|
|
19
|
+
@ApiPropertyOptional({ enum: ['info', 'warning', 'success', 'error'], default: 'info' })
|
|
20
|
+
@IsOptional()
|
|
21
|
+
@IsEnum(['info', 'warning', 'success', 'error'], {
|
|
22
|
+
message: 'El tipo debe ser "info", "warning", "success" o "error"',
|
|
23
|
+
})
|
|
24
|
+
type?: 'info' | 'warning' | 'success' | 'error';
|
|
25
|
+
|
|
26
|
+
@ApiPropertyOptional({ example: '/invoices/123' })
|
|
27
|
+
@IsOptional()
|
|
28
|
+
@IsString({ message: 'El link debe ser un texto' })
|
|
29
|
+
link?: string;
|
|
30
|
+
|
|
31
|
+
@ApiPropertyOptional({ example: '507f1f77bcf86cd799439011' })
|
|
32
|
+
@IsOptional()
|
|
33
|
+
@IsString({ message: 'El refId debe ser un texto' })
|
|
34
|
+
refId?: string;
|
|
35
|
+
|
|
36
|
+
@ApiPropertyOptional({ example: 'invoice' })
|
|
37
|
+
@IsOptional()
|
|
38
|
+
@IsString({ message: 'El refType debe ser un texto' })
|
|
39
|
+
refType?: string;
|
|
40
|
+
|
|
41
|
+
@ApiPropertyOptional({ default: false })
|
|
42
|
+
@IsOptional()
|
|
43
|
+
@IsBoolean({ message: 'isRead debe ser un booleano' })
|
|
44
|
+
isRead?: boolean;
|
|
45
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Transform, Type } from 'class-transformer';
|
|
3
|
+
import { IsBoolean, IsInt, IsOptional, Max, Min } from 'class-validator';
|
|
4
|
+
|
|
5
|
+
export class QueryNotificationDto {
|
|
6
|
+
@ApiPropertyOptional({ description: 'Filtrar por estado de lectura' })
|
|
7
|
+
@IsOptional()
|
|
8
|
+
@Transform(({ value }) => {
|
|
9
|
+
if (value === 'true') return true;
|
|
10
|
+
if (value === 'false') return false;
|
|
11
|
+
return value;
|
|
12
|
+
})
|
|
13
|
+
@IsBoolean({ message: 'isRead debe ser un booleano' })
|
|
14
|
+
isRead?: boolean;
|
|
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,119 @@
|
|
|
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 { CreateNotificationDto } from './dto/create-notification.dto';
|
|
25
|
+
import { QueryNotificationDto } from './dto/query-notification.dto';
|
|
26
|
+
import { NotificationsService } from './notifications.service';
|
|
27
|
+
|
|
28
|
+
@ApiTags('Notifications')
|
|
29
|
+
@ApiBearerAuth()
|
|
30
|
+
@UseGuards(JwtAuthGuard)
|
|
31
|
+
@Controller('notifications')
|
|
32
|
+
export class NotificationsController {
|
|
33
|
+
constructor(private readonly notificationsService: NotificationsService) {}
|
|
34
|
+
|
|
35
|
+
@Get()
|
|
36
|
+
@ApiOperation({ summary: 'Listar notificaciones del usuario' })
|
|
37
|
+
@ApiResponse({ status: 200, description: 'Lista paginada de notificaciones' })
|
|
38
|
+
findAll(
|
|
39
|
+
@WorkspaceId() workspaceId: string,
|
|
40
|
+
@CurrentUser() user: TokenPayload,
|
|
41
|
+
@Query() query: QueryNotificationDto,
|
|
42
|
+
) {
|
|
43
|
+
return this.notificationsService.findAll(workspaceId, user.userId, query);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Get('unread-count')
|
|
47
|
+
@ApiOperation({ summary: 'Obtener cantidad de notificaciones no leídas' })
|
|
48
|
+
@ApiResponse({ status: 200, description: '{ count: number }' })
|
|
49
|
+
getUnreadCount(
|
|
50
|
+
@WorkspaceId() workspaceId: string,
|
|
51
|
+
@CurrentUser() user: TokenPayload,
|
|
52
|
+
) {
|
|
53
|
+
return this.notificationsService.getUnreadCount(workspaceId, user.userId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Post('mark-all-read')
|
|
57
|
+
@ApiOperation({ summary: 'Marcar todas las notificaciones como leídas' })
|
|
58
|
+
@ApiResponse({ status: 200, description: 'Todas marcadas como leídas' })
|
|
59
|
+
markAllAsRead(
|
|
60
|
+
@WorkspaceId() workspaceId: string,
|
|
61
|
+
@CurrentUser() user: TokenPayload,
|
|
62
|
+
) {
|
|
63
|
+
return this.notificationsService.markAllAsRead(workspaceId, user.userId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Get(':id')
|
|
67
|
+
@ApiOperation({ summary: 'Obtener una notificación por ID' })
|
|
68
|
+
@ApiResponse({ status: 200, description: 'Notificación encontrada' })
|
|
69
|
+
@ApiResponse({ status: 404, description: 'Notificación no encontrada' })
|
|
70
|
+
findOne(
|
|
71
|
+
@WorkspaceId() workspaceId: string,
|
|
72
|
+
@Param('id') id: string,
|
|
73
|
+
) {
|
|
74
|
+
return this.notificationsService.findOne(workspaceId, id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Post()
|
|
78
|
+
@ApiOperation({ summary: 'Crear una notificación' })
|
|
79
|
+
@ApiResponse({ status: 201, description: 'Notificación creada' })
|
|
80
|
+
create(
|
|
81
|
+
@WorkspaceId() workspaceId: string,
|
|
82
|
+
@CurrentUser() user: TokenPayload,
|
|
83
|
+
@Body() dto: CreateNotificationDto,
|
|
84
|
+
) {
|
|
85
|
+
return this.notificationsService.create(workspaceId, user.userId, dto);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Patch(':id/read')
|
|
89
|
+
@ApiOperation({ summary: 'Marcar notificación como leída' })
|
|
90
|
+
@ApiResponse({ status: 200, description: 'Notificación marcada como leída' })
|
|
91
|
+
markAsRead(
|
|
92
|
+
@WorkspaceId() workspaceId: string,
|
|
93
|
+
@Param('id') id: string,
|
|
94
|
+
) {
|
|
95
|
+
return this.notificationsService.markAsRead(workspaceId, id);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@Patch(':id/unread')
|
|
99
|
+
@ApiOperation({ summary: 'Marcar notificación como no leída' })
|
|
100
|
+
@ApiResponse({ status: 200, description: 'Notificación marcada como no leída' })
|
|
101
|
+
markAsUnread(
|
|
102
|
+
@WorkspaceId() workspaceId: string,
|
|
103
|
+
@Param('id') id: string,
|
|
104
|
+
) {
|
|
105
|
+
return this.notificationsService.markAsUnread(workspaceId, id);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
@Delete(':id')
|
|
109
|
+
@HttpCode(HttpStatus.NO_CONTENT)
|
|
110
|
+
@ApiOperation({ summary: 'Eliminar una notificación (soft delete)' })
|
|
111
|
+
@ApiResponse({ status: 204, description: 'Notificación eliminada' })
|
|
112
|
+
@ApiResponse({ status: 404, description: 'Notificación no encontrada' })
|
|
113
|
+
remove(
|
|
114
|
+
@WorkspaceId() workspaceId: string,
|
|
115
|
+
@Param('id') id: string,
|
|
116
|
+
) {
|
|
117
|
+
return this.notificationsService.remove(workspaceId, id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
3
|
+
import { Notification, NotificationSchema } from './schemas/notification.schema';
|
|
4
|
+
import { NotificationsController } from './notifications.controller';
|
|
5
|
+
import { NotificationsRepository } from './notifications.repository';
|
|
6
|
+
import { NotificationsService } from './notifications.service';
|
|
7
|
+
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [
|
|
10
|
+
MongooseModule.forFeature([{ name: Notification.name, schema: NotificationSchema }]),
|
|
11
|
+
],
|
|
12
|
+
controllers: [NotificationsController],
|
|
13
|
+
providers: [NotificationsService, NotificationsRepository],
|
|
14
|
+
exports: [NotificationsService],
|
|
15
|
+
})
|
|
16
|
+
export class NotificationsModule {}
|
|
@@ -0,0 +1,31 @@
|
|
|
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 { Notification, NotificationDocument } from './schemas/notification.schema';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class NotificationsRepository extends BaseRepository<NotificationDocument> {
|
|
9
|
+
constructor(
|
|
10
|
+
@InjectModel(Notification.name)
|
|
11
|
+
private readonly notificationModel: Model<NotificationDocument>,
|
|
12
|
+
) {
|
|
13
|
+
super(notificationModel);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async markAllAsRead(workspaceId: string, userId: string): Promise<void> {
|
|
17
|
+
await this.notificationModel.updateMany(
|
|
18
|
+
{ workspaceId, userId, isRead: false, deletedAt: null },
|
|
19
|
+
{ $set: { isRead: true } },
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async getUnreadCount(workspaceId: string, userId: string): Promise<number> {
|
|
24
|
+
return this.notificationModel.countDocuments({
|
|
25
|
+
workspaceId,
|
|
26
|
+
userId,
|
|
27
|
+
isRead: false,
|
|
28
|
+
deletedAt: null,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { CreateNotificationDto } from './dto/create-notification.dto';
|
|
3
|
+
import { QueryNotificationDto } from './dto/query-notification.dto';
|
|
4
|
+
import { NotificationsRepository } from './notifications.repository';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class NotificationsService {
|
|
8
|
+
constructor(private readonly notificationsRepository: NotificationsRepository) {}
|
|
9
|
+
|
|
10
|
+
findAll(workspaceId: string, userId: string, query: QueryNotificationDto) {
|
|
11
|
+
const { page, limit, isRead } = query;
|
|
12
|
+
const filter: Record<string, unknown> = { userId };
|
|
13
|
+
if (isRead !== undefined) filter.isRead = isRead;
|
|
14
|
+
|
|
15
|
+
return this.notificationsRepository.paginate(workspaceId, {
|
|
16
|
+
page,
|
|
17
|
+
limit,
|
|
18
|
+
filter,
|
|
19
|
+
sort: { createdAt: -1 },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async findOne(workspaceId: string, id: string) {
|
|
24
|
+
const notification = await this.notificationsRepository.findById(workspaceId, id);
|
|
25
|
+
if (!notification) throw new NotFoundException('Notificación no encontrada');
|
|
26
|
+
return notification;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
create(workspaceId: string, userId: string, dto: CreateNotificationDto) {
|
|
30
|
+
return this.notificationsRepository.create({
|
|
31
|
+
...dto,
|
|
32
|
+
workspaceId: workspaceId as unknown as import('mongoose').Types.ObjectId,
|
|
33
|
+
createdBy: userId as unknown as import('mongoose').Types.ObjectId,
|
|
34
|
+
deletedAt: null,
|
|
35
|
+
} as never);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async markAsRead(workspaceId: string, id: string) {
|
|
39
|
+
const notification = await this.notificationsRepository.findById(workspaceId, id);
|
|
40
|
+
if (!notification) throw new NotFoundException('Notificación no encontrada');
|
|
41
|
+
return this.notificationsRepository.update(workspaceId, id, { isRead: true } as never);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async markAsUnread(workspaceId: string, id: string) {
|
|
45
|
+
const notification = await this.notificationsRepository.findById(workspaceId, id);
|
|
46
|
+
if (!notification) throw new NotFoundException('Notificación no encontrada');
|
|
47
|
+
return this.notificationsRepository.update(workspaceId, id, { isRead: false } as never);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async markAllAsRead(workspaceId: string, userId: string) {
|
|
51
|
+
await this.notificationsRepository.markAllAsRead(workspaceId, userId);
|
|
52
|
+
return { message: 'Todas las notificaciones marcadas como leídas' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async getUnreadCount(workspaceId: string, userId: string) {
|
|
56
|
+
const count = await this.notificationsRepository.getUnreadCount(workspaceId, userId);
|
|
57
|
+
return { count };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async remove(workspaceId: string, id: string) {
|
|
61
|
+
await this.findOne(workspaceId, id);
|
|
62
|
+
await this.notificationsRepository.softDelete(workspaceId, id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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 NotificationDocument = HydratedDocument<Notification>;
|
|
6
|
+
|
|
7
|
+
@Schema({ collection: 'notifications', timestamps: true })
|
|
8
|
+
export class Notification extends BaseSchema {
|
|
9
|
+
@Prop({ required: true })
|
|
10
|
+
userId!: string;
|
|
11
|
+
|
|
12
|
+
@Prop({ required: true, trim: true, maxlength: 120 })
|
|
13
|
+
title!: string;
|
|
14
|
+
|
|
15
|
+
@Prop({ required: true, trim: true, maxlength: 500 })
|
|
16
|
+
message!: string;
|
|
17
|
+
|
|
18
|
+
@Prop({ type: String, enum: ['info', 'warning', 'success', 'error'], default: 'info' })
|
|
19
|
+
type!: 'info' | 'warning' | 'success' | 'error';
|
|
20
|
+
|
|
21
|
+
@Prop({ trim: true })
|
|
22
|
+
link!: string;
|
|
23
|
+
|
|
24
|
+
@Prop({ default: false })
|
|
25
|
+
isRead!: boolean;
|
|
26
|
+
|
|
27
|
+
@Prop({ trim: true })
|
|
28
|
+
refId!: string;
|
|
29
|
+
|
|
30
|
+
@Prop({ trim: true })
|
|
31
|
+
refType!: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const NotificationSchema = SchemaFactory.createForClass(Notification);
|
|
35
|
+
|
|
36
|
+
NotificationSchema.index({ workspaceId: 1, userId: 1 });
|
|
37
|
+
NotificationSchema.index({ workspaceId: 1, userId: 1, isRead: 1 });
|
|
38
|
+
NotificationSchema.index({ workspaceId: 1, createdAt: -1 });
|