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,104 @@
|
|
|
1
|
+
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { CreatePlannerBlockDto } from './dto/create-planner-block.dto';
|
|
3
|
+
import { QueryPlannerBlockDto } from './dto/query-planner-block.dto';
|
|
4
|
+
import { UpdateBlockStatusDto } from './dto/update-block-status.dto';
|
|
5
|
+
import { UpdatePlannerBlockDto } from './dto/update-planner-block.dto';
|
|
6
|
+
import { PlannerRepository } from './planner.repository';
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class PlannerService {
|
|
10
|
+
constructor(private readonly plannerRepository: PlannerRepository) {}
|
|
11
|
+
|
|
12
|
+
async findAll(workspaceId: string, userId: string, query: QueryPlannerBlockDto) {
|
|
13
|
+
const { date, dateFrom, dateTo, status, category, page, limit } = query;
|
|
14
|
+
|
|
15
|
+
if (date) {
|
|
16
|
+
const blocks = await this.plannerRepository.findByDate(workspaceId, userId, date);
|
|
17
|
+
return { data: blocks, total: blocks.length, page: 1, limit: blocks.length };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (dateFrom && dateTo) {
|
|
21
|
+
const dayDiff = this.daysBetween(dateFrom, dateTo);
|
|
22
|
+
if (dayDiff > 31) {
|
|
23
|
+
throw new BadRequestException('El rango de fechas no puede superar los 31 días');
|
|
24
|
+
}
|
|
25
|
+
const blocks = await this.plannerRepository.findByDateRange(workspaceId, userId, dateFrom, dateTo);
|
|
26
|
+
return { data: blocks, total: blocks.length, page: 1, limit: blocks.length };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const filter: Record<string, unknown> = { userId };
|
|
30
|
+
if (status) filter.status = status;
|
|
31
|
+
if (category) filter.category = category;
|
|
32
|
+
|
|
33
|
+
return this.plannerRepository.paginate(workspaceId, {
|
|
34
|
+
page,
|
|
35
|
+
limit,
|
|
36
|
+
filter,
|
|
37
|
+
sort: { date: 1, order: 1 },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async findOne(workspaceId: string, userId: string, id: string) {
|
|
42
|
+
const block = await this.plannerRepository.findOne(workspaceId, { _id: this.plannerRepository['toObjectId'](id), userId });
|
|
43
|
+
if (!block) throw new NotFoundException('Bloque no encontrado');
|
|
44
|
+
return block;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
create(workspaceId: string, userId: string, dto: CreatePlannerBlockDto) {
|
|
48
|
+
return this.plannerRepository.create({
|
|
49
|
+
...dto,
|
|
50
|
+
userId,
|
|
51
|
+
workspaceId: workspaceId as unknown as import('mongoose').Types.ObjectId,
|
|
52
|
+
createdBy: userId as unknown as import('mongoose').Types.ObjectId,
|
|
53
|
+
deletedAt: null,
|
|
54
|
+
} as never);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async update(workspaceId: string, userId: string, id: string, dto: UpdatePlannerBlockDto) {
|
|
58
|
+
await this.findOne(workspaceId, userId, id);
|
|
59
|
+
const block = await this.plannerRepository.update(workspaceId, id, dto as never);
|
|
60
|
+
if (!block) throw new NotFoundException('Bloque no encontrado');
|
|
61
|
+
return block;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async updateStatus(workspaceId: string, userId: string, id: string, dto: UpdateBlockStatusDto) {
|
|
65
|
+
await this.findOne(workspaceId, userId, id);
|
|
66
|
+
const block = await this.plannerRepository.update(workspaceId, id, { status: dto.status } as never);
|
|
67
|
+
if (!block) throw new NotFoundException('Bloque no encontrado');
|
|
68
|
+
return block;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async duplicate(workspaceId: string, userId: string, id: string, targetDate?: string) {
|
|
72
|
+
const original = await this.findOne(workspaceId, userId, id);
|
|
73
|
+
const orig = original as unknown as Record<string, unknown>;
|
|
74
|
+
return this.plannerRepository.create({
|
|
75
|
+
userId,
|
|
76
|
+
date: targetDate ?? (orig.date as string),
|
|
77
|
+
startTime: orig.startTime as string,
|
|
78
|
+
endTime: orig.endTime as string,
|
|
79
|
+
title: orig.title as string,
|
|
80
|
+
description: orig.description as string,
|
|
81
|
+
category: orig.category as string,
|
|
82
|
+
priority: orig.priority as 'low' | 'medium' | 'high',
|
|
83
|
+
color: orig.color as string,
|
|
84
|
+
isFocusBlock: orig.isFocusBlock as boolean,
|
|
85
|
+
tags: orig.tags as string[],
|
|
86
|
+
order: ((orig.order as number) ?? 0) + 1,
|
|
87
|
+
status: 'pending',
|
|
88
|
+
workspaceId: workspaceId as unknown as import('mongoose').Types.ObjectId,
|
|
89
|
+
createdBy: userId as unknown as import('mongoose').Types.ObjectId,
|
|
90
|
+
deletedAt: null,
|
|
91
|
+
} as never);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async remove(workspaceId: string, userId: string, id: string) {
|
|
95
|
+
await this.findOne(workspaceId, userId, id);
|
|
96
|
+
await this.plannerRepository.softDelete(workspaceId, id);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private daysBetween(from: string, to: string): number {
|
|
100
|
+
const a = new Date(from).getTime();
|
|
101
|
+
const b = new Date(to).getTime();
|
|
102
|
+
return Math.abs(Math.round((b - a) / (1000 * 60 * 60 * 24)));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 PlannerBlockDocument = HydratedDocument<PlannerBlock>;
|
|
6
|
+
|
|
7
|
+
@Schema({ collection: 'planner_blocks', timestamps: true })
|
|
8
|
+
export class PlannerBlock extends BaseSchema {
|
|
9
|
+
@Prop({ required: true })
|
|
10
|
+
userId!: string;
|
|
11
|
+
|
|
12
|
+
@Prop({ required: true })
|
|
13
|
+
date!: string;
|
|
14
|
+
|
|
15
|
+
@Prop({ required: true })
|
|
16
|
+
startTime!: string;
|
|
17
|
+
|
|
18
|
+
@Prop({ required: true })
|
|
19
|
+
endTime!: string;
|
|
20
|
+
|
|
21
|
+
@Prop({ required: true, trim: true, maxlength: 120 })
|
|
22
|
+
title!: string;
|
|
23
|
+
|
|
24
|
+
@Prop({ trim: true })
|
|
25
|
+
description!: string;
|
|
26
|
+
|
|
27
|
+
@Prop({ default: 'work' })
|
|
28
|
+
category!: string;
|
|
29
|
+
|
|
30
|
+
@Prop({ type: String, enum: ['low', 'medium', 'high'], default: 'medium' })
|
|
31
|
+
priority!: 'low' | 'medium' | 'high';
|
|
32
|
+
|
|
33
|
+
@Prop({
|
|
34
|
+
type: String,
|
|
35
|
+
enum: ['pending', 'in-progress', 'completed', 'skipped'],
|
|
36
|
+
default: 'pending',
|
|
37
|
+
})
|
|
38
|
+
status!: 'pending' | 'in-progress' | 'completed' | 'skipped';
|
|
39
|
+
|
|
40
|
+
@Prop({ default: '#2563EB' })
|
|
41
|
+
color!: string;
|
|
42
|
+
|
|
43
|
+
@Prop({ default: false })
|
|
44
|
+
isFocusBlock!: boolean;
|
|
45
|
+
|
|
46
|
+
@Prop({ default: 0 })
|
|
47
|
+
order!: number;
|
|
48
|
+
|
|
49
|
+
@Prop({ type: [String], default: [] })
|
|
50
|
+
tags!: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const PlannerBlockSchema = SchemaFactory.createForClass(PlannerBlock);
|
|
54
|
+
|
|
55
|
+
PlannerBlockSchema.index({ workspaceId: 1, userId: 1, date: 1 });
|
|
56
|
+
PlannerBlockSchema.index({ workspaceId: 1, userId: 1, date: 1, status: 1 });
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class CreateTaskColumnDto {
|
|
5
|
+
@ApiProperty({ example: 'En progreso', maxLength: 100 })
|
|
6
|
+
@IsString({ message: 'El nombre debe ser un texto' })
|
|
7
|
+
@MaxLength(100, { message: 'El nombre no puede superar los 100 caracteres' })
|
|
8
|
+
name!: string;
|
|
9
|
+
|
|
10
|
+
@ApiPropertyOptional({ example: '#6B7280' })
|
|
11
|
+
@IsOptional()
|
|
12
|
+
@IsString({ message: 'El color debe ser un texto' })
|
|
13
|
+
color?: string;
|
|
14
|
+
|
|
15
|
+
@ApiPropertyOptional({ example: 0, minimum: 0 })
|
|
16
|
+
@IsOptional()
|
|
17
|
+
@IsInt({ message: 'El orden debe ser un número entero' })
|
|
18
|
+
@Min(0, { message: 'El orden no puede ser negativo' })
|
|
19
|
+
order?: number;
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { IsArray, IsString } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class ReorderColumnsDto {
|
|
5
|
+
@ApiProperty({ example: ['id1', 'id2', 'id3'], description: 'IDs de columnas en el nuevo orden' })
|
|
6
|
+
@IsArray({ message: 'Los ids deben ser un array' })
|
|
7
|
+
@IsString({ each: true, message: 'Cada id debe ser un texto' })
|
|
8
|
+
ids!: string[];
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
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 TaskColumnDocument = HydratedDocument<TaskColumn>;
|
|
6
|
+
|
|
7
|
+
@Schema({ collection: 'task_columns', timestamps: true })
|
|
8
|
+
export class TaskColumn extends BaseSchema {
|
|
9
|
+
@Prop({ required: true, trim: true, maxlength: 100 })
|
|
10
|
+
name!: string;
|
|
11
|
+
|
|
12
|
+
@Prop({ default: '#6B7280' })
|
|
13
|
+
color!: string;
|
|
14
|
+
|
|
15
|
+
@Prop({ default: 0 })
|
|
16
|
+
order!: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const TaskColumnSchema = SchemaFactory.createForClass(TaskColumn);
|
|
20
|
+
|
|
21
|
+
TaskColumnSchema.index({ workspaceId: 1, order: 1 });
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Delete,
|
|
5
|
+
Get,
|
|
6
|
+
HttpCode,
|
|
7
|
+
HttpStatus,
|
|
8
|
+
Param,
|
|
9
|
+
Patch,
|
|
10
|
+
Post,
|
|
11
|
+
UseGuards,
|
|
12
|
+
} from '@nestjs/common';
|
|
13
|
+
import {
|
|
14
|
+
ApiBearerAuth,
|
|
15
|
+
ApiOperation,
|
|
16
|
+
ApiResponse,
|
|
17
|
+
ApiTags,
|
|
18
|
+
} from '@nestjs/swagger';
|
|
19
|
+
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|
20
|
+
import type { TokenPayload } from '../../common/decorators/current-user.decorator';
|
|
21
|
+
import { WorkspaceId } from '../../common/decorators/workspace-id.decorator';
|
|
22
|
+
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|
23
|
+
import { CreateTaskColumnDto } from './dto/create-task-column.dto';
|
|
24
|
+
import { ReorderColumnsDto } from './dto/reorder-columns.dto';
|
|
25
|
+
import { UpdateTaskColumnDto } from './dto/update-task-column.dto';
|
|
26
|
+
import { TaskColumnsService } from './task-columns.service';
|
|
27
|
+
|
|
28
|
+
@ApiTags('Task Columns')
|
|
29
|
+
@ApiBearerAuth()
|
|
30
|
+
@UseGuards(JwtAuthGuard)
|
|
31
|
+
@Controller('task-columns')
|
|
32
|
+
export class TaskColumnsController {
|
|
33
|
+
constructor(private readonly taskColumnsService: TaskColumnsService) {}
|
|
34
|
+
|
|
35
|
+
@Get()
|
|
36
|
+
@ApiOperation({ summary: 'Listar columnas del workspace' })
|
|
37
|
+
@ApiResponse({ status: 200, description: 'Lista de columnas ordenadas' })
|
|
38
|
+
findAll(@WorkspaceId() workspaceId: string) {
|
|
39
|
+
return this.taskColumnsService.findAll(workspaceId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@Post()
|
|
43
|
+
@ApiOperation({ summary: 'Crear una nueva columna' })
|
|
44
|
+
@ApiResponse({ status: 201, description: 'Columna creada' })
|
|
45
|
+
create(
|
|
46
|
+
@WorkspaceId() workspaceId: string,
|
|
47
|
+
@CurrentUser() user: TokenPayload,
|
|
48
|
+
@Body() dto: CreateTaskColumnDto,
|
|
49
|
+
) {
|
|
50
|
+
return this.taskColumnsService.create(workspaceId, user.userId, dto);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
@Patch('reorder')
|
|
54
|
+
@ApiOperation({ summary: 'Reordenar columnas' })
|
|
55
|
+
@ApiResponse({ status: 200, description: 'Columnas reordenadas' })
|
|
56
|
+
reorder(
|
|
57
|
+
@WorkspaceId() workspaceId: string,
|
|
58
|
+
@Body() dto: ReorderColumnsDto,
|
|
59
|
+
) {
|
|
60
|
+
return this.taskColumnsService.reorder(workspaceId, dto);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@Patch(':id')
|
|
64
|
+
@ApiOperation({ summary: 'Actualizar una columna' })
|
|
65
|
+
@ApiResponse({ status: 200, description: 'Columna actualizada' })
|
|
66
|
+
@ApiResponse({ status: 404, description: 'Columna no encontrada' })
|
|
67
|
+
update(
|
|
68
|
+
@WorkspaceId() workspaceId: string,
|
|
69
|
+
@Param('id') id: string,
|
|
70
|
+
@Body() dto: UpdateTaskColumnDto,
|
|
71
|
+
) {
|
|
72
|
+
return this.taskColumnsService.update(workspaceId, id, dto);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Delete(':id')
|
|
76
|
+
@HttpCode(HttpStatus.NO_CONTENT)
|
|
77
|
+
@ApiOperation({ summary: 'Eliminar una columna (soft delete)' })
|
|
78
|
+
@ApiResponse({ status: 204, description: 'Columna eliminada' })
|
|
79
|
+
@ApiResponse({ status: 404, description: 'Columna no encontrada' })
|
|
80
|
+
remove(
|
|
81
|
+
@WorkspaceId() workspaceId: string,
|
|
82
|
+
@Param('id') id: string,
|
|
83
|
+
) {
|
|
84
|
+
return this.taskColumnsService.remove(workspaceId, id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
3
|
+
import { TaskColumn, TaskColumnSchema } from './schemas/task-column.schema';
|
|
4
|
+
import { TaskColumnsController } from './task-columns.controller';
|
|
5
|
+
import { TaskColumnsRepository } from './task-columns.repository';
|
|
6
|
+
import { TaskColumnsService } from './task-columns.service';
|
|
7
|
+
|
|
8
|
+
@Module({
|
|
9
|
+
imports: [
|
|
10
|
+
MongooseModule.forFeature([{ name: TaskColumn.name, schema: TaskColumnSchema }]),
|
|
11
|
+
],
|
|
12
|
+
controllers: [TaskColumnsController],
|
|
13
|
+
providers: [TaskColumnsService, TaskColumnsRepository],
|
|
14
|
+
exports: [TaskColumnsService],
|
|
15
|
+
})
|
|
16
|
+
export class TaskColumnsModule {}
|
|
@@ -0,0 +1,15 @@
|
|
|
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 { TaskColumn, TaskColumnDocument } from './schemas/task-column.schema';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class TaskColumnsRepository extends BaseRepository<TaskColumnDocument> {
|
|
9
|
+
constructor(
|
|
10
|
+
@InjectModel(TaskColumn.name)
|
|
11
|
+
private readonly taskColumnModel: Model<TaskColumnDocument>,
|
|
12
|
+
) {
|
|
13
|
+
super(taskColumnModel);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { CreateTaskColumnDto } from './dto/create-task-column.dto';
|
|
3
|
+
import { ReorderColumnsDto } from './dto/reorder-columns.dto';
|
|
4
|
+
import { UpdateTaskColumnDto } from './dto/update-task-column.dto';
|
|
5
|
+
import { TaskColumnsRepository } from './task-columns.repository';
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class TaskColumnsService {
|
|
9
|
+
constructor(private readonly taskColumnsRepository: TaskColumnsRepository) {}
|
|
10
|
+
|
|
11
|
+
findAll(workspaceId: string) {
|
|
12
|
+
return this.taskColumnsRepository.findAll(workspaceId, { sort: { order: 1 } });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async findOne(workspaceId: string, id: string) {
|
|
16
|
+
const column = await this.taskColumnsRepository.findById(workspaceId, id);
|
|
17
|
+
if (!column) throw new NotFoundException('Columna no encontrada');
|
|
18
|
+
return column;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
create(workspaceId: string, userId: string, dto: CreateTaskColumnDto) {
|
|
22
|
+
return this.taskColumnsRepository.create({
|
|
23
|
+
...dto,
|
|
24
|
+
workspaceId: workspaceId as unknown as import('mongoose').Types.ObjectId,
|
|
25
|
+
createdBy: userId as unknown as import('mongoose').Types.ObjectId,
|
|
26
|
+
deletedAt: null,
|
|
27
|
+
} as never);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async update(workspaceId: string, id: string, dto: UpdateTaskColumnDto) {
|
|
31
|
+
await this.findOne(workspaceId, id);
|
|
32
|
+
const column = await this.taskColumnsRepository.update(workspaceId, id, dto as never);
|
|
33
|
+
if (!column) throw new NotFoundException('Columna no encontrada');
|
|
34
|
+
return column;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async reorder(workspaceId: string, dto: ReorderColumnsDto) {
|
|
38
|
+
const updates = dto.ids.map((id, index) =>
|
|
39
|
+
this.taskColumnsRepository.update(workspaceId, id, { order: index } as never),
|
|
40
|
+
);
|
|
41
|
+
await Promise.all(updates);
|
|
42
|
+
return this.taskColumnsRepository.findAll(workspaceId, { sort: { order: 1 } });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async remove(workspaceId: string, id: string) {
|
|
46
|
+
await this.findOne(workspaceId, id);
|
|
47
|
+
await this.taskColumnsRepository.softDelete(workspaceId, id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { IsBoolean, IsOptional, IsString } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class ChecklistItemDto {
|
|
5
|
+
@ApiProperty({ example: 'Revisar especificaciones' })
|
|
6
|
+
@IsString({ message: 'El texto debe ser un texto' })
|
|
7
|
+
text!: string;
|
|
8
|
+
|
|
9
|
+
@ApiPropertyOptional({ default: false })
|
|
10
|
+
@IsOptional()
|
|
11
|
+
@IsBoolean({ message: 'completed debe ser un booleano' })
|
|
12
|
+
completed?: boolean;
|
|
13
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { Type } from 'class-transformer';
|
|
3
|
+
import {
|
|
4
|
+
IsArray,
|
|
5
|
+
IsDateString,
|
|
6
|
+
IsEnum,
|
|
7
|
+
IsInt,
|
|
8
|
+
IsOptional,
|
|
9
|
+
IsString,
|
|
10
|
+
MaxLength,
|
|
11
|
+
Min,
|
|
12
|
+
ValidateNested,
|
|
13
|
+
} from 'class-validator';
|
|
14
|
+
import { ChecklistItemDto } from './checklist-item.dto';
|
|
15
|
+
import { LabelDto } from './label.dto';
|
|
16
|
+
|
|
17
|
+
export class CreateTaskDto {
|
|
18
|
+
@ApiProperty({ example: 'Implementar login', maxLength: 200 })
|
|
19
|
+
@IsString({ message: 'El título debe ser un texto' })
|
|
20
|
+
@MaxLength(200, { message: 'El título no puede superar los 200 caracteres' })
|
|
21
|
+
title!: string;
|
|
22
|
+
|
|
23
|
+
@ApiPropertyOptional()
|
|
24
|
+
@IsOptional()
|
|
25
|
+
@IsString({ message: 'La descripción debe ser un texto' })
|
|
26
|
+
description?: string;
|
|
27
|
+
|
|
28
|
+
@ApiPropertyOptional({ enum: ['todo', 'in-progress', 'done', 'cancelled'], default: 'todo' })
|
|
29
|
+
@IsOptional()
|
|
30
|
+
@IsEnum(['todo', 'in-progress', 'done', 'cancelled'], { message: 'El estado no es válido' })
|
|
31
|
+
status?: 'todo' | 'in-progress' | 'done' | 'cancelled';
|
|
32
|
+
|
|
33
|
+
@ApiPropertyOptional({ enum: ['low', 'medium', 'high', 'urgent'], default: 'medium' })
|
|
34
|
+
@IsOptional()
|
|
35
|
+
@IsEnum(['low', 'medium', 'high', 'urgent'], { message: 'La prioridad no es válida' })
|
|
36
|
+
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
|
37
|
+
|
|
38
|
+
@ApiPropertyOptional({ example: '2026-03-31' })
|
|
39
|
+
@IsOptional()
|
|
40
|
+
@IsDateString({}, { message: 'La fecha de vencimiento debe ser una fecha válida' })
|
|
41
|
+
dueDate?: string;
|
|
42
|
+
|
|
43
|
+
@ApiPropertyOptional({ description: 'ID de la columna (null para sin columna)' })
|
|
44
|
+
@IsOptional()
|
|
45
|
+
@IsString({ message: 'El columnId debe ser un texto' })
|
|
46
|
+
columnId?: string | null;
|
|
47
|
+
|
|
48
|
+
@ApiPropertyOptional({ example: 0, minimum: 0 })
|
|
49
|
+
@IsOptional()
|
|
50
|
+
@IsInt({ message: 'El orden debe ser un número entero' })
|
|
51
|
+
@Min(0, { message: 'El orden no puede ser negativo' })
|
|
52
|
+
order?: number;
|
|
53
|
+
|
|
54
|
+
@ApiPropertyOptional({ type: [ChecklistItemDto] })
|
|
55
|
+
@IsOptional()
|
|
56
|
+
@IsArray({ message: 'El checklist debe ser un array' })
|
|
57
|
+
@ValidateNested({ each: true })
|
|
58
|
+
@Type(() => ChecklistItemDto)
|
|
59
|
+
checklist?: ChecklistItemDto[];
|
|
60
|
+
|
|
61
|
+
@ApiPropertyOptional({ type: [LabelDto] })
|
|
62
|
+
@IsOptional()
|
|
63
|
+
@IsArray({ message: 'Las labels deben ser un array' })
|
|
64
|
+
@ValidateNested({ each: true })
|
|
65
|
+
@Type(() => LabelDto)
|
|
66
|
+
labels?: LabelDto[];
|
|
67
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { IsString } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class LabelDto {
|
|
5
|
+
@ApiProperty({ example: 'Bug' })
|
|
6
|
+
@IsString({ message: 'El nombre debe ser un texto' })
|
|
7
|
+
name!: string;
|
|
8
|
+
|
|
9
|
+
@ApiProperty({ example: '#EF4444' })
|
|
10
|
+
@IsString({ message: 'El color debe ser un texto' })
|
|
11
|
+
color!: string;
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
2
|
+
import { IsInt, IsOptional, IsString, Min, ValidateIf } from 'class-validator';
|
|
3
|
+
|
|
4
|
+
export class MoveTaskDto {
|
|
5
|
+
@ApiProperty({ description: 'ID de la columna destino o null para sin columna', nullable: true })
|
|
6
|
+
@ValidateIf((o: MoveTaskDto) => o.columnId !== null)
|
|
7
|
+
@IsString({ message: 'El columnId debe ser un texto' })
|
|
8
|
+
columnId!: string | null;
|
|
9
|
+
|
|
10
|
+
@ApiPropertyOptional({ example: 0, minimum: 0 })
|
|
11
|
+
@IsOptional()
|
|
12
|
+
@IsInt({ message: 'El orden debe ser un número entero' })
|
|
13
|
+
@Min(0, { message: 'El orden no puede ser negativo' })
|
|
14
|
+
order?: number;
|
|
15
|
+
}
|
|
@@ -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 QueryTaskDto {
|
|
6
|
+
@ApiPropertyOptional()
|
|
7
|
+
@IsOptional()
|
|
8
|
+
@IsString({ message: 'La búsqueda debe ser un texto' })
|
|
9
|
+
search?: string;
|
|
10
|
+
|
|
11
|
+
@ApiPropertyOptional({ enum: ['todo', 'in-progress', 'done', 'cancelled'] })
|
|
12
|
+
@IsOptional()
|
|
13
|
+
@IsEnum(['todo', 'in-progress', 'done', 'cancelled'], { message: 'El estado no es válido' })
|
|
14
|
+
status?: 'todo' | 'in-progress' | 'done' | 'cancelled';
|
|
15
|
+
|
|
16
|
+
@ApiPropertyOptional({ enum: ['low', 'medium', 'high', 'urgent'] })
|
|
17
|
+
@IsOptional()
|
|
18
|
+
@IsEnum(['low', 'medium', 'high', 'urgent'], { message: 'La prioridad no es válida' })
|
|
19
|
+
priority?: 'low' | 'medium' | 'high' | 'urgent';
|
|
20
|
+
|
|
21
|
+
@ApiPropertyOptional()
|
|
22
|
+
@IsOptional()
|
|
23
|
+
@IsString({ message: 'El columnId debe ser un texto' })
|
|
24
|
+
columnId?: 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: 100, minimum: 1, maximum: 200 })
|
|
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(200, { message: 'El límite máximo es 200' })
|
|
39
|
+
limit?: number = 100;
|
|
40
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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 TaskDocument = HydratedDocument<Task>;
|
|
6
|
+
|
|
7
|
+
export interface ChecklistItem {
|
|
8
|
+
text: string;
|
|
9
|
+
completed: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface TaskLabel {
|
|
13
|
+
name: string;
|
|
14
|
+
color: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Schema({ collection: 'tasks', timestamps: true })
|
|
18
|
+
export class Task extends BaseSchema {
|
|
19
|
+
@Prop({ required: true, trim: true, maxlength: 200 })
|
|
20
|
+
title!: string;
|
|
21
|
+
|
|
22
|
+
@Prop({ trim: true })
|
|
23
|
+
description!: string;
|
|
24
|
+
|
|
25
|
+
@Prop({
|
|
26
|
+
type: String,
|
|
27
|
+
enum: ['todo', 'in-progress', 'done', 'cancelled'],
|
|
28
|
+
default: 'todo',
|
|
29
|
+
})
|
|
30
|
+
status!: 'todo' | 'in-progress' | 'done' | 'cancelled';
|
|
31
|
+
|
|
32
|
+
@Prop({
|
|
33
|
+
type: String,
|
|
34
|
+
enum: ['low', 'medium', 'high', 'urgent'],
|
|
35
|
+
default: 'medium',
|
|
36
|
+
})
|
|
37
|
+
priority!: 'low' | 'medium' | 'high' | 'urgent';
|
|
38
|
+
|
|
39
|
+
@Prop({ type: Date })
|
|
40
|
+
dueDate!: Date;
|
|
41
|
+
|
|
42
|
+
@Prop({ type: String, default: null })
|
|
43
|
+
columnId!: string | null;
|
|
44
|
+
|
|
45
|
+
@Prop({ default: 0 })
|
|
46
|
+
order!: number;
|
|
47
|
+
|
|
48
|
+
@Prop({
|
|
49
|
+
type: [{ text: { type: String, required: true }, completed: { type: Boolean, default: false } }],
|
|
50
|
+
default: [],
|
|
51
|
+
})
|
|
52
|
+
checklist!: ChecklistItem[];
|
|
53
|
+
|
|
54
|
+
@Prop({
|
|
55
|
+
type: [{ name: { type: String, required: true }, color: { type: String, required: true } }],
|
|
56
|
+
default: [],
|
|
57
|
+
})
|
|
58
|
+
labels!: TaskLabel[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const TaskSchema = SchemaFactory.createForClass(Task);
|
|
62
|
+
|
|
63
|
+
TaskSchema.index({ workspaceId: 1, status: 1 });
|
|
64
|
+
TaskSchema.index({ workspaceId: 1, columnId: 1, order: 1 });
|
|
65
|
+
TaskSchema.index({ workspaceId: 1, priority: 1 });
|
|
66
|
+
TaskSchema.index({ workspaceId: 1, dueDate: 1 });
|