feat: implement Phase 2 Core features for AutoScheduler GTD System
- ConnectWise Manage Integration: - ConnectWiseModule with service, controller, entity - API endpoints for connection CRUD and sync - Syncs service tickets, project tickets, zero-ticket projects - Stores ConnectWise priority/SLA in task metadata - Intelligent Calendar Scheduling: - CalendarModule with connection and event entities - Support for CalDAV, Microsoft Graph, Google Calendar providers - CalendarService with sync methods for all providers - SchedulingModule with automatic scheduling engine - Finds available slots respecting working hours - Groups tasks by context, respects priority and due dates - Interactive Calendar Week View: - FullCalendar with timeGridWeek view - Drag-and-drop task rescheduling - Tasks auto-lock when manually moved - Color-coded by context - Regenerate Schedule button Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"migration:revert": "typeorm migration:revert -d src/config/typeorm.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
@@ -31,12 +32,14 @@
|
||||
"@nestjs/typeorm": "^10.0.1",
|
||||
"@nestjs/websockets": "^10.3.0",
|
||||
"@nick-tracker/shared-types": "*",
|
||||
"axios": "^1.13.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bull": "^4.12.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"date-fns": "^3.2.0",
|
||||
"helmet": "^7.1.0",
|
||||
"ical.js": "^2.2.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
|
||||
@@ -8,10 +8,16 @@ import { InboxModule } from './modules/inbox/inbox.module';
|
||||
import { TasksModule } from './modules/tasks/tasks.module';
|
||||
import { ProjectsModule } from './modules/projects/projects.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { ConnectWiseModule } from './modules/connectwise/connectwise.module';
|
||||
import { CalendarModule } from './modules/calendar/calendar.module';
|
||||
import { SchedulingModule } from './modules/schedule/schedule.module';
|
||||
import { User } from './modules/users/entities/user.entity';
|
||||
import { InboxItem } from './modules/inbox/entities/inbox-item.entity';
|
||||
import { Task } from './modules/tasks/entities/task.entity';
|
||||
import { Project } from './modules/projects/entities/project.entity';
|
||||
import { ConnectWiseConnection } from './modules/connectwise/entities/connectwise-connection.entity';
|
||||
import { CalendarConnection } from './modules/calendar/entities/calendar-connection.entity';
|
||||
import { CalendarEvent } from './modules/calendar/entities/calendar-event.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,7 +34,15 @@ import { Project } from './modules/projects/entities/project.entity';
|
||||
username: configService.get('DB_USERNAME', 'postgres'),
|
||||
password: configService.get('DB_PASSWORD', 'postgres'),
|
||||
database: configService.get('DB_DATABASE', 'autoscheduler'),
|
||||
entities: [User, InboxItem, Task, Project],
|
||||
entities: [
|
||||
User,
|
||||
InboxItem,
|
||||
Task,
|
||||
Project,
|
||||
ConnectWiseConnection,
|
||||
CalendarConnection,
|
||||
CalendarEvent,
|
||||
],
|
||||
synchronize: configService.get('NODE_ENV') !== 'production',
|
||||
logging: configService.get('NODE_ENV') !== 'production',
|
||||
}),
|
||||
@@ -41,6 +55,9 @@ import { Project } from './modules/projects/entities/project.entity';
|
||||
TasksModule,
|
||||
ProjectsModule,
|
||||
HealthModule,
|
||||
ConnectWiseModule,
|
||||
CalendarModule,
|
||||
SchedulingModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
73
packages/backend/src/modules/calendar/calendar.controller.ts
Normal file
73
packages/backend/src/modules/calendar/calendar.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { CreateCalendarConnectionDto } from './dto/create-calendar-connection.dto';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
}
|
||||
|
||||
@Controller('api/v1')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CalendarController {
|
||||
constructor(private readonly calendarService: CalendarService) {}
|
||||
|
||||
@Post('connections/calendar')
|
||||
async createConnection(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Body() createDto: CreateCalendarConnectionDto,
|
||||
) {
|
||||
const connection = await this.calendarService.createConnection(req.user.id, createDto);
|
||||
return this.calendarService.toConnectionResponseDto(connection);
|
||||
}
|
||||
|
||||
@Get('connections/calendar')
|
||||
async findAllConnections(@Request() req: AuthenticatedRequest) {
|
||||
const connections = await this.calendarService.findAllConnections(req.user.id);
|
||||
return connections.map((c) => this.calendarService.toConnectionResponseDto(c));
|
||||
}
|
||||
|
||||
@Get('connections/calendar/:id')
|
||||
async findConnectionById(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const connection = await this.calendarService.findConnectionById(id, req.user.id);
|
||||
if (!connection) {
|
||||
return { error: 'Not found' };
|
||||
}
|
||||
return this.calendarService.toConnectionResponseDto(connection);
|
||||
}
|
||||
|
||||
@Delete('connections/calendar/:id')
|
||||
async deleteConnection(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
await this.calendarService.deleteConnection(id, req.user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('connections/calendar/:id/sync')
|
||||
async syncCalendar(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const count = await this.calendarService.syncCalendar(id, req.user.id);
|
||||
return { syncedCount: count };
|
||||
}
|
||||
|
||||
@Get('calendar/events')
|
||||
async findEvents(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Query('start') start: string,
|
||||
@Query('end') end: string,
|
||||
) {
|
||||
const startDate = start ? new Date(start) : new Date();
|
||||
const endDate = end ? new Date(end) : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const events = await this.calendarService.findEvents(req.user.id, startDate, endDate);
|
||||
return events.map((e) => this.calendarService.toEventResponseDto(e));
|
||||
}
|
||||
}
|
||||
14
packages/backend/src/modules/calendar/calendar.module.ts
Normal file
14
packages/backend/src/modules/calendar/calendar.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CalendarController } from './calendar.controller';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { CalendarConnection } from './entities/calendar-connection.entity';
|
||||
import { CalendarEvent } from './entities/calendar-event.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([CalendarConnection, CalendarEvent])],
|
||||
controllers: [CalendarController],
|
||||
providers: [CalendarService],
|
||||
exports: [CalendarService],
|
||||
})
|
||||
export class CalendarModule {}
|
||||
383
packages/backend/src/modules/calendar/calendar.service.ts
Normal file
383
packages/backend/src/modules/calendar/calendar.service.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import axios from 'axios';
|
||||
import { CalendarConnection } from './entities/calendar-connection.entity';
|
||||
import { CalendarEvent } from './entities/calendar-event.entity';
|
||||
import { CreateCalendarConnectionDto } from './dto/create-calendar-connection.dto';
|
||||
import { CalendarProvider } from '@nick-tracker/shared-types';
|
||||
import { addDays, startOfDay, endOfDay } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class CalendarService {
|
||||
private readonly logger = new Logger(CalendarService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(CalendarConnection)
|
||||
private readonly connectionRepository: Repository<CalendarConnection>,
|
||||
@InjectRepository(CalendarEvent)
|
||||
private readonly eventRepository: Repository<CalendarEvent>,
|
||||
) {}
|
||||
|
||||
async createConnection(
|
||||
userId: string,
|
||||
createDto: CreateCalendarConnectionDto,
|
||||
): Promise<CalendarConnection> {
|
||||
const connection = this.connectionRepository.create({
|
||||
...createDto,
|
||||
userId,
|
||||
});
|
||||
return this.connectionRepository.save(connection);
|
||||
}
|
||||
|
||||
async findAllConnections(userId: string): Promise<CalendarConnection[]> {
|
||||
return this.connectionRepository.find({ where: { userId } });
|
||||
}
|
||||
|
||||
async findConnectionById(id: string, userId: string): Promise<CalendarConnection | null> {
|
||||
return this.connectionRepository.findOne({ where: { id, userId } });
|
||||
}
|
||||
|
||||
async deleteConnection(id: string, userId: string): Promise<void> {
|
||||
const connection = await this.findConnectionById(id, userId);
|
||||
if (!connection) {
|
||||
throw new NotFoundException('Calendar connection not found');
|
||||
}
|
||||
await this.connectionRepository.remove(connection);
|
||||
}
|
||||
|
||||
async syncCalendar(connectionId: string, userId: string): Promise<number> {
|
||||
const connection = await this.findConnectionById(connectionId, userId);
|
||||
if (!connection) {
|
||||
throw new NotFoundException('Calendar connection not found');
|
||||
}
|
||||
|
||||
let syncedCount = 0;
|
||||
|
||||
switch (connection.provider) {
|
||||
case CalendarProvider.CALDAV:
|
||||
syncedCount = await this.syncCalDAV(connection, userId);
|
||||
break;
|
||||
case CalendarProvider.MICROSOFT_GRAPH:
|
||||
syncedCount = await this.syncMicrosoftGraph(connection, userId);
|
||||
break;
|
||||
case CalendarProvider.GOOGLE:
|
||||
syncedCount = await this.syncGoogleCalendar(connection, userId);
|
||||
break;
|
||||
}
|
||||
|
||||
connection.lastSync = new Date();
|
||||
await this.connectionRepository.save(connection);
|
||||
|
||||
return syncedCount;
|
||||
}
|
||||
|
||||
private async syncCalDAV(connection: CalendarConnection, userId: string): Promise<number> {
|
||||
if (!connection.calendarUrl || !connection.credentials) {
|
||||
this.logger.warn('CalDAV connection missing URL or credentials');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const startDate = startOfDay(new Date());
|
||||
const endDate = endOfDay(addDays(new Date(), 30));
|
||||
|
||||
try {
|
||||
// Build CalDAV REPORT request for calendar-query
|
||||
const calendarQuery = `<?xml version="1.0" encoding="utf-8" ?>
|
||||
<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
<C:calendar-data/>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:comp-filter name="VCALENDAR">
|
||||
<C:comp-filter name="VEVENT">
|
||||
<C:time-range start="${startDate.toISOString().replace(/[-:]/g, '').split('.')[0]}Z"
|
||||
end="${endDate.toISOString().replace(/[-:]/g, '').split('.')[0]}Z"/>
|
||||
</C:comp-filter>
|
||||
</C:comp-filter>
|
||||
</C:filter>
|
||||
</C:calendar-query>`;
|
||||
|
||||
const authToken = Buffer.from(
|
||||
`${connection.credentials.username}:${connection.credentials.password}`,
|
||||
).toString('base64');
|
||||
|
||||
const response = await axios({
|
||||
method: 'REPORT',
|
||||
url: connection.calendarUrl,
|
||||
headers: {
|
||||
Authorization: `Basic ${authToken}`,
|
||||
'Content-Type': 'application/xml',
|
||||
Depth: '1',
|
||||
},
|
||||
data: calendarQuery,
|
||||
});
|
||||
|
||||
const events = this.parseICalEvents(response.data, connection.provider);
|
||||
let syncedCount = 0;
|
||||
|
||||
for (const event of events) {
|
||||
await this.upsertEvent(userId, connection.id, event);
|
||||
syncedCount++;
|
||||
}
|
||||
|
||||
return syncedCount;
|
||||
} catch (error) {
|
||||
this.logger.error(`CalDAV sync failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncMicrosoftGraph(
|
||||
connection: CalendarConnection,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (!connection.credentials?.accessToken) {
|
||||
this.logger.warn('Microsoft Graph connection missing access token');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const startDate = new Date();
|
||||
const endDate = addDays(startDate, 30);
|
||||
|
||||
try {
|
||||
const response = await axios.get('https://graph.microsoft.com/v1.0/me/calendarView', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.credentials.accessToken}`,
|
||||
},
|
||||
params: {
|
||||
startDateTime: startDate.toISOString(),
|
||||
endDateTime: endDate.toISOString(),
|
||||
$select: 'id,subject,start,end,isAllDay',
|
||||
},
|
||||
});
|
||||
|
||||
const events = response.data.value || [];
|
||||
let syncedCount = 0;
|
||||
|
||||
for (const msEvent of events) {
|
||||
await this.upsertEvent(userId, connection.id, {
|
||||
externalId: msEvent.id,
|
||||
title: msEvent.subject || 'Untitled',
|
||||
startTime: new Date(msEvent.start.dateTime),
|
||||
endTime: new Date(msEvent.end.dateTime),
|
||||
isAllDay: msEvent.isAllDay || false,
|
||||
source: CalendarProvider.MICROSOFT_GRAPH,
|
||||
});
|
||||
syncedCount++;
|
||||
}
|
||||
|
||||
return syncedCount;
|
||||
} catch (error) {
|
||||
this.logger.error(`Microsoft Graph sync failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async syncGoogleCalendar(
|
||||
connection: CalendarConnection,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (!connection.credentials?.accessToken) {
|
||||
this.logger.warn('Google Calendar connection missing access token');
|
||||
return 0;
|
||||
}
|
||||
|
||||
const startDate = new Date();
|
||||
const endDate = addDays(startDate, 30);
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
'https://www.googleapis.com/calendar/v3/calendars/primary/events',
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${connection.credentials.accessToken}`,
|
||||
},
|
||||
params: {
|
||||
timeMin: startDate.toISOString(),
|
||||
timeMax: endDate.toISOString(),
|
||||
singleEvents: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const events = response.data.items || [];
|
||||
let syncedCount = 0;
|
||||
|
||||
for (const gEvent of events) {
|
||||
const startTime = gEvent.start.dateTime
|
||||
? new Date(gEvent.start.dateTime)
|
||||
: new Date(gEvent.start.date);
|
||||
const endTime = gEvent.end.dateTime
|
||||
? new Date(gEvent.end.dateTime)
|
||||
: new Date(gEvent.end.date);
|
||||
const isAllDay = !gEvent.start.dateTime;
|
||||
|
||||
await this.upsertEvent(userId, connection.id, {
|
||||
externalId: gEvent.id,
|
||||
title: gEvent.summary || 'Untitled',
|
||||
startTime,
|
||||
endTime,
|
||||
isAllDay,
|
||||
source: CalendarProvider.GOOGLE,
|
||||
});
|
||||
syncedCount++;
|
||||
}
|
||||
|
||||
return syncedCount;
|
||||
} catch (error) {
|
||||
this.logger.error(`Google Calendar sync failed: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private parseICalEvents(
|
||||
icalData: string,
|
||||
source: CalendarProvider,
|
||||
): Array<{
|
||||
externalId: string;
|
||||
title: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
isAllDay: boolean;
|
||||
source: CalendarProvider;
|
||||
}> {
|
||||
const events: Array<{
|
||||
externalId: string;
|
||||
title: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
isAllDay: boolean;
|
||||
source: CalendarProvider;
|
||||
}> = [];
|
||||
|
||||
// Basic iCal parsing - extract VEVENT components
|
||||
const veventRegex = /BEGIN:VEVENT[\s\S]*?END:VEVENT/g;
|
||||
const matches = icalData.match(veventRegex) || [];
|
||||
|
||||
for (const vevent of matches) {
|
||||
const uid = this.extractICalProperty(vevent, 'UID');
|
||||
const summary = this.extractICalProperty(vevent, 'SUMMARY') || 'Untitled';
|
||||
const dtstart = this.extractICalProperty(vevent, 'DTSTART');
|
||||
const dtend = this.extractICalProperty(vevent, 'DTEND');
|
||||
|
||||
if (uid && dtstart) {
|
||||
const isAllDay = !dtstart.includes('T');
|
||||
const startTime = this.parseICalDate(dtstart);
|
||||
const endTime = dtend
|
||||
? this.parseICalDate(dtend)
|
||||
: new Date(startTime.getTime() + 60 * 60 * 1000);
|
||||
|
||||
events.push({
|
||||
externalId: uid,
|
||||
title: summary,
|
||||
startTime,
|
||||
endTime,
|
||||
isAllDay,
|
||||
source,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
private extractICalProperty(vevent: string, property: string): string | null {
|
||||
const regex = new RegExp(`${property}[^:]*:([^\\r\\n]+)`, 'i');
|
||||
const match = vevent.match(regex);
|
||||
return match ? match[1].trim() : null;
|
||||
}
|
||||
|
||||
private parseICalDate(dateStr: string): Date {
|
||||
// Handle both date-only and datetime formats
|
||||
if (dateStr.includes('T')) {
|
||||
// DateTime format: 20240115T120000Z or 20240115T120000
|
||||
const year = parseInt(dateStr.substring(0, 4));
|
||||
const month = parseInt(dateStr.substring(4, 6)) - 1;
|
||||
const day = parseInt(dateStr.substring(6, 8));
|
||||
const hour = parseInt(dateStr.substring(9, 11));
|
||||
const minute = parseInt(dateStr.substring(11, 13));
|
||||
const second = parseInt(dateStr.substring(13, 15)) || 0;
|
||||
|
||||
if (dateStr.endsWith('Z')) {
|
||||
return new Date(Date.UTC(year, month, day, hour, minute, second));
|
||||
}
|
||||
return new Date(year, month, day, hour, minute, second);
|
||||
} else {
|
||||
// Date-only format: 20240115
|
||||
const year = parseInt(dateStr.substring(0, 4));
|
||||
const month = parseInt(dateStr.substring(4, 6)) - 1;
|
||||
const day = parseInt(dateStr.substring(6, 8));
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertEvent(
|
||||
userId: string,
|
||||
connectionId: string,
|
||||
eventData: {
|
||||
externalId: string;
|
||||
title: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
isAllDay: boolean;
|
||||
source: CalendarProvider;
|
||||
},
|
||||
): Promise<CalendarEvent> {
|
||||
let event = await this.eventRepository.findOne({
|
||||
where: {
|
||||
userId,
|
||||
externalId: eventData.externalId,
|
||||
source: eventData.source,
|
||||
},
|
||||
});
|
||||
|
||||
if (event) {
|
||||
Object.assign(event, eventData);
|
||||
} else {
|
||||
event = this.eventRepository.create({
|
||||
...eventData,
|
||||
userId,
|
||||
connectionId,
|
||||
});
|
||||
}
|
||||
|
||||
return this.eventRepository.save(event);
|
||||
}
|
||||
|
||||
async findEvents(userId: string, startDate: Date, endDate: Date): Promise<CalendarEvent[]> {
|
||||
return this.eventRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
startTime: Between(startDate, endDate),
|
||||
},
|
||||
order: { startTime: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
toConnectionResponseDto(connection: CalendarConnection) {
|
||||
return {
|
||||
id: connection.id,
|
||||
provider: connection.provider,
|
||||
calendarUrl: connection.calendarUrl ?? undefined,
|
||||
lastSync: connection.lastSync?.toISOString(),
|
||||
createdAt: connection.createdAt.toISOString(),
|
||||
updatedAt: connection.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
toEventResponseDto(event: CalendarEvent) {
|
||||
return {
|
||||
id: event.id,
|
||||
externalId: event.externalId,
|
||||
title: event.title,
|
||||
startTime: event.startTime.toISOString(),
|
||||
endTime: event.endTime.toISOString(),
|
||||
isAllDay: event.isAllDay,
|
||||
source: event.source,
|
||||
createdAt: event.createdAt.toISOString(),
|
||||
updatedAt: event.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IsEnum, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CalendarProvider } from '@nick-tracker/shared-types';
|
||||
|
||||
class CredentialsDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
username?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
password?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
accessToken?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export class CreateCalendarConnectionDto {
|
||||
@IsEnum(CalendarProvider)
|
||||
provider: CalendarProvider;
|
||||
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
calendarUrl?: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => CredentialsDto)
|
||||
@IsOptional()
|
||||
credentials?: CredentialsDto;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { CalendarProvider } from '@nick-tracker/shared-types';
|
||||
|
||||
@Entity('calendar_connections')
|
||||
export class CalendarConnection {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CalendarProvider,
|
||||
})
|
||||
provider: CalendarProvider;
|
||||
|
||||
@Column({ nullable: true })
|
||||
calendarUrl: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
credentials: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
} | null;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
lastSync: Date | null;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { CalendarConnection } from './calendar-connection.entity';
|
||||
import { CalendarProvider } from '@nick-tracker/shared-types';
|
||||
|
||||
@Entity('calendar_events')
|
||||
export class CalendarEvent {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
externalId: string;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
startTime: Date;
|
||||
|
||||
@Column({ type: 'timestamp' })
|
||||
endTime: Date;
|
||||
|
||||
@Column({ default: false })
|
||||
isAllDay: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: CalendarProvider,
|
||||
})
|
||||
source: CalendarProvider;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
connectionId: string | null;
|
||||
|
||||
@ManyToOne(() => CalendarConnection, { onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'connectionId' })
|
||||
connection: CalendarConnection | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Controller, Get, Post, Delete, Param, Body, UseGuards, Request } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ConnectWiseService } from './connectwise.service';
|
||||
import { CreateConnectWiseConnectionDto } from './dto/create-connectwise-connection.dto';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
}
|
||||
|
||||
@Controller('api/v1/connections/connectwise')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ConnectWiseController {
|
||||
constructor(private readonly connectWiseService: ConnectWiseService) {}
|
||||
|
||||
@Post()
|
||||
async create(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Body() createDto: CreateConnectWiseConnectionDto,
|
||||
) {
|
||||
const connection = await this.connectWiseService.create(req.user.id, createDto);
|
||||
return this.connectWiseService.toResponseDto(connection);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(@Request() req: AuthenticatedRequest) {
|
||||
const connections = await this.connectWiseService.findAll(req.user.id);
|
||||
return connections.map((c) => this.connectWiseService.toResponseDto(c));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async findById(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const connection = await this.connectWiseService.findById(id, req.user.id);
|
||||
if (!connection) {
|
||||
return { error: 'Not found' };
|
||||
}
|
||||
return this.connectWiseService.toResponseDto(connection);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
await this.connectWiseService.delete(id, req.user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/sync')
|
||||
async sync(@Request() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const count = await this.connectWiseService.syncTickets(id, req.user.id);
|
||||
return { syncedCount: count };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConnectWiseController } from './connectwise.controller';
|
||||
import { ConnectWiseService } from './connectwise.service';
|
||||
import { ConnectWiseConnection } from './entities/connectwise-connection.entity';
|
||||
import { InboxModule } from '../inbox/inbox.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ConnectWiseConnection]), InboxModule],
|
||||
controllers: [ConnectWiseController],
|
||||
providers: [ConnectWiseService],
|
||||
exports: [ConnectWiseService],
|
||||
})
|
||||
export class ConnectWiseModule {}
|
||||
200
packages/backend/src/modules/connectwise/connectwise.service.ts
Normal file
200
packages/backend/src/modules/connectwise/connectwise.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import { ConnectWiseConnection } from './entities/connectwise-connection.entity';
|
||||
import { CreateConnectWiseConnectionDto } from './dto/create-connectwise-connection.dto';
|
||||
import { InboxService } from '../inbox/inbox.service';
|
||||
import { InboxSource } from '@nick-tracker/shared-types';
|
||||
|
||||
interface ConnectWiseTicket {
|
||||
id: number;
|
||||
summary: string;
|
||||
priority?: { name?: string };
|
||||
sla?: { name?: string };
|
||||
status?: { name?: string };
|
||||
company?: { name?: string };
|
||||
project?: { id: number; name: string };
|
||||
}
|
||||
|
||||
interface ConnectWiseProject {
|
||||
id: number;
|
||||
name: string;
|
||||
status?: { name?: string };
|
||||
company?: { name?: string };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ConnectWiseService {
|
||||
private readonly logger = new Logger(ConnectWiseService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ConnectWiseConnection)
|
||||
private readonly connectionRepository: Repository<ConnectWiseConnection>,
|
||||
private readonly inboxService: InboxService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
createDto: CreateConnectWiseConnectionDto,
|
||||
): Promise<ConnectWiseConnection> {
|
||||
const connection = this.connectionRepository.create({
|
||||
...createDto,
|
||||
userId,
|
||||
});
|
||||
return this.connectionRepository.save(connection);
|
||||
}
|
||||
|
||||
async findAll(userId: string): Promise<ConnectWiseConnection[]> {
|
||||
return this.connectionRepository.find({ where: { userId } });
|
||||
}
|
||||
|
||||
async findById(id: string, userId: string): Promise<ConnectWiseConnection | null> {
|
||||
return this.connectionRepository.findOne({ where: { id, userId } });
|
||||
}
|
||||
|
||||
async delete(id: string, userId: string): Promise<void> {
|
||||
const connection = await this.findById(id, userId);
|
||||
if (!connection) {
|
||||
throw new NotFoundException('ConnectWise connection not found');
|
||||
}
|
||||
await this.connectionRepository.remove(connection);
|
||||
}
|
||||
|
||||
private createApiClient(connection: ConnectWiseConnection): AxiosInstance {
|
||||
const authToken = Buffer.from(
|
||||
`${connection.companyId}+${connection.publicKey}:${connection.privateKey}`,
|
||||
).toString('base64');
|
||||
|
||||
return axios.create({
|
||||
baseURL: connection.apiUrl,
|
||||
headers: {
|
||||
Authorization: `Basic ${authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
clientId: connection.companyId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async syncTickets(connectionId: string, userId: string): Promise<number> {
|
||||
const connection = await this.findById(connectionId, userId);
|
||||
if (!connection) {
|
||||
throw new NotFoundException('ConnectWise connection not found');
|
||||
}
|
||||
|
||||
const client = this.createApiClient(connection);
|
||||
let syncedCount = 0;
|
||||
|
||||
try {
|
||||
// Sync service tickets assigned to member
|
||||
const serviceTicketsResponse = await client.get('/service/tickets', {
|
||||
params: {
|
||||
conditions: `resources/id=${connection.memberId}`,
|
||||
pageSize: 100,
|
||||
},
|
||||
});
|
||||
const serviceTickets: ConnectWiseTicket[] = serviceTicketsResponse.data || [];
|
||||
|
||||
for (const ticket of serviceTickets) {
|
||||
await this.createInboxItemFromTicket(userId, ticket, 'service');
|
||||
syncedCount++;
|
||||
}
|
||||
|
||||
// Sync project tickets assigned to member
|
||||
const projectTicketsResponse = await client.get('/project/tickets', {
|
||||
params: {
|
||||
conditions: `resources/id=${connection.memberId}`,
|
||||
pageSize: 100,
|
||||
},
|
||||
});
|
||||
const projectTickets: ConnectWiseTicket[] = projectTicketsResponse.data || [];
|
||||
|
||||
for (const ticket of projectTickets) {
|
||||
await this.createInboxItemFromTicket(userId, ticket, 'project');
|
||||
syncedCount++;
|
||||
}
|
||||
|
||||
// Sync projects assigned to member and check for zero-ticket projects
|
||||
const projectsResponse = await client.get('/project/projects', {
|
||||
params: {
|
||||
conditions: `manager/id=${connection.memberId}`,
|
||||
pageSize: 100,
|
||||
},
|
||||
});
|
||||
const projects: ConnectWiseProject[] = projectsResponse.data || [];
|
||||
|
||||
for (const project of projects) {
|
||||
// Check if project has any tickets
|
||||
const ticketsResponse = await client.get(`/project/projects/${project.id}/tickets`, {
|
||||
params: { pageSize: 1 },
|
||||
});
|
||||
const hasTickets = (ticketsResponse.data?.length || 0) > 0;
|
||||
|
||||
if (!hasTickets) {
|
||||
// Create planning task for zero-ticket project
|
||||
await this.inboxService.create(
|
||||
userId,
|
||||
{
|
||||
content: `Plan project: ${project.name}`,
|
||||
sourceMetadata: {
|
||||
connectwiseProjectId: project.id,
|
||||
projectName: project.name,
|
||||
company: project.company?.name,
|
||||
status: project.status?.name,
|
||||
type: 'planning',
|
||||
},
|
||||
},
|
||||
InboxSource.CONNECTWISE,
|
||||
);
|
||||
syncedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync timestamp
|
||||
connection.lastSync = new Date();
|
||||
await this.connectionRepository.save(connection);
|
||||
|
||||
this.logger.log(`Synced ${syncedCount} items from ConnectWise for user ${userId}`);
|
||||
return syncedCount;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to sync ConnectWise tickets: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async createInboxItemFromTicket(
|
||||
userId: string,
|
||||
ticket: ConnectWiseTicket,
|
||||
ticketType: 'service' | 'project',
|
||||
): Promise<void> {
|
||||
await this.inboxService.create(
|
||||
userId,
|
||||
{
|
||||
content: ticket.summary,
|
||||
sourceMetadata: {
|
||||
ticketId: ticket.id,
|
||||
ticketType,
|
||||
priority: ticket.priority?.name,
|
||||
sla: ticket.sla?.name,
|
||||
status: ticket.status?.name,
|
||||
company: ticket.company?.name,
|
||||
projectId: ticket.project?.id,
|
||||
projectName: ticket.project?.name,
|
||||
},
|
||||
},
|
||||
InboxSource.CONNECTWISE,
|
||||
);
|
||||
}
|
||||
|
||||
toResponseDto(connection: ConnectWiseConnection) {
|
||||
return {
|
||||
id: connection.id,
|
||||
companyId: connection.companyId,
|
||||
apiUrl: connection.apiUrl,
|
||||
memberId: connection.memberId,
|
||||
lastSync: connection.lastSync?.toISOString(),
|
||||
createdAt: connection.createdAt.toISOString(),
|
||||
updatedAt: connection.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { IsString, IsNotEmpty, IsUrl } from 'class-validator';
|
||||
|
||||
export class CreateConnectWiseConnectionDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
companyId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
publicKey: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
privateKey: string;
|
||||
|
||||
@IsUrl()
|
||||
@IsNotEmpty()
|
||||
apiUrl: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
memberId: string;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
|
||||
@Entity('connectwise_connections')
|
||||
export class ConnectWiseConnection {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column()
|
||||
companyId: string;
|
||||
|
||||
@Column()
|
||||
publicKey: string;
|
||||
|
||||
@Column()
|
||||
privateKey: string;
|
||||
|
||||
@Column()
|
||||
apiUrl: string;
|
||||
|
||||
@Column()
|
||||
memberId: string;
|
||||
|
||||
@Column({ type: 'timestamp', nullable: true })
|
||||
lastSync: Date | null;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
userId: string;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'userId' })
|
||||
user: User;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CreateInboxItemDto } from './dto/create-inbox-item.dto';
|
||||
import { ProcessInboxItemDto } from './dto/process-inbox-item.dto';
|
||||
|
||||
@Controller('inbox')
|
||||
@Controller('api/v1/inbox')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InboxController {
|
||||
constructor(private readonly inboxService: InboxService) {}
|
||||
@@ -20,12 +20,7 @@ export class InboxController {
|
||||
@Request() req: { user: { userId: string } },
|
||||
@Body() createDto: CreateInboxItemDto,
|
||||
) {
|
||||
const item = await this.inboxService.create(
|
||||
req.user.userId,
|
||||
createDto.content,
|
||||
undefined,
|
||||
createDto.sourceMetadata,
|
||||
);
|
||||
const item = await this.inboxService.create(req.user.userId, createDto);
|
||||
return this.inboxService.toResponseDto(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('InboxService', () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an inbox item', async () => {
|
||||
const result = await service.create('test-user-id', 'Test content');
|
||||
const result = await service.create('test-user-id', { content: 'Test content' });
|
||||
|
||||
expect(repository.create).toHaveBeenCalledWith({
|
||||
userId: 'test-user-id',
|
||||
|
||||
@@ -23,15 +23,14 @@ export class InboxService {
|
||||
|
||||
async create(
|
||||
userId: string,
|
||||
content: string,
|
||||
dto: { content: string; sourceMetadata?: Record<string, unknown> },
|
||||
source: InboxSource = InboxSource.MANUAL,
|
||||
sourceMetadata?: Record<string, unknown>,
|
||||
): Promise<InboxItem> {
|
||||
const item = this.inboxRepository.create({
|
||||
userId,
|
||||
content,
|
||||
content: dto.content,
|
||||
source,
|
||||
sourceMetadata: sourceMetadata ?? null,
|
||||
sourceMetadata: dto.sourceMetadata ?? null,
|
||||
processed: false,
|
||||
});
|
||||
return this.inboxRepository.save(item);
|
||||
|
||||
32
packages/backend/src/modules/schedule/schedule.controller.ts
Normal file
32
packages/backend/src/modules/schedule/schedule.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Controller, Post, Delete, UseGuards, Request } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { ScheduleService } from './schedule.service';
|
||||
import { TasksService } from '../tasks/tasks.service';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
}
|
||||
|
||||
@Controller('api/v1/schedule')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ScheduleController {
|
||||
constructor(
|
||||
private readonly scheduleService: ScheduleService,
|
||||
private readonly tasksService: TasksService,
|
||||
) {}
|
||||
|
||||
@Post('regenerate')
|
||||
async regenerate(@Request() req: AuthenticatedRequest) {
|
||||
const result = await this.scheduleService.regenerateSchedule(req.user.id);
|
||||
return {
|
||||
scheduledCount: result.scheduledCount,
|
||||
tasks: result.tasks.map((t) => this.tasksService.toResponseDto(t)),
|
||||
};
|
||||
}
|
||||
|
||||
@Delete('clear')
|
||||
async clear(@Request() req: AuthenticatedRequest) {
|
||||
const clearedCount = await this.scheduleService.clearSchedule(req.user.id);
|
||||
return { clearedCount };
|
||||
}
|
||||
}
|
||||
16
packages/backend/src/modules/schedule/schedule.module.ts
Normal file
16
packages/backend/src/modules/schedule/schedule.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleController } from './schedule.controller';
|
||||
import { ScheduleService } from './schedule.service';
|
||||
import { Task } from '../tasks/entities/task.entity';
|
||||
import { CalendarEvent } from '../calendar/entities/calendar-event.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { TasksModule } from '../tasks/tasks.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Task, CalendarEvent, User]), TasksModule],
|
||||
controllers: [ScheduleController],
|
||||
providers: [ScheduleService],
|
||||
exports: [ScheduleService],
|
||||
})
|
||||
export class SchedulingModule {}
|
||||
265
packages/backend/src/modules/schedule/schedule.service.ts
Normal file
265
packages/backend/src/modules/schedule/schedule.service.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not, IsNull } from 'typeorm';
|
||||
import {
|
||||
addMinutes,
|
||||
startOfDay,
|
||||
addDays,
|
||||
setHours,
|
||||
setMinutes,
|
||||
isAfter,
|
||||
isBefore,
|
||||
areIntervalsOverlapping,
|
||||
getDay,
|
||||
} from 'date-fns';
|
||||
import { Task } from '../tasks/entities/task.entity';
|
||||
import { CalendarEvent } from '../calendar/entities/calendar-event.entity';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { TaskStatus, type WorkingHours } from '@nick-tracker/shared-types';
|
||||
|
||||
interface TimeSlot {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
const DEFAULT_WORKING_HOURS: WorkingHours = {
|
||||
monday: { start: '09:00', end: '17:00' },
|
||||
tuesday: { start: '09:00', end: '17:00' },
|
||||
wednesday: { start: '09:00', end: '17:00' },
|
||||
thursday: { start: '09:00', end: '17:00' },
|
||||
friday: { start: '09:00', end: '17:00' },
|
||||
};
|
||||
|
||||
const DAY_NAMES = [
|
||||
'sunday',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ScheduleService {
|
||||
private readonly logger = new Logger(ScheduleService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepository: Repository<Task>,
|
||||
@InjectRepository(CalendarEvent)
|
||||
private readonly eventRepository: Repository<CalendarEvent>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async regenerateSchedule(userId: string): Promise<{ scheduledCount: number; tasks: Task[] }> {
|
||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
|
||||
const workingHours = user.workingHours || DEFAULT_WORKING_HOURS;
|
||||
|
||||
// Get unscheduled NEXT_ACTION tasks that are not locked
|
||||
const unscheduledTasks = await this.taskRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
status: TaskStatus.NEXT_ACTION,
|
||||
isLocked: false,
|
||||
scheduledStart: IsNull(),
|
||||
},
|
||||
order: {
|
||||
priority: { direction: 'ASC', nulls: 'LAST' },
|
||||
dueDate: { direction: 'ASC', nulls: 'LAST' },
|
||||
createdAt: 'ASC',
|
||||
},
|
||||
});
|
||||
|
||||
if (unscheduledTasks.length === 0) {
|
||||
return { scheduledCount: 0, tasks: [] };
|
||||
}
|
||||
|
||||
// Get existing calendar events for the next 14 days
|
||||
const startDate = new Date();
|
||||
const endDate = addDays(startDate, 14);
|
||||
|
||||
const calendarEvents = await this.eventRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
// Get already scheduled tasks (locked ones)
|
||||
const scheduledTasks = await this.taskRepository.find({
|
||||
where: {
|
||||
userId,
|
||||
scheduledStart: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
// Build list of blocked time slots
|
||||
const blockedSlots: TimeSlot[] = [
|
||||
...calendarEvents.map((e) => ({ start: e.startTime, end: e.endTime })),
|
||||
...scheduledTasks
|
||||
.filter((t) => t.scheduledStart && t.scheduledEnd)
|
||||
.map((t) => ({ start: t.scheduledStart!, end: t.scheduledEnd! })),
|
||||
];
|
||||
|
||||
// Group tasks by context for batching
|
||||
const tasksByContext = this.groupTasksByContext(unscheduledTasks);
|
||||
const scheduledResults: Task[] = [];
|
||||
|
||||
// Process each context group
|
||||
for (const [, tasks] of Object.entries(tasksByContext)) {
|
||||
// Sort by priority and due date within context
|
||||
const sortedTasks = tasks.sort((a, b) => {
|
||||
if (a.priority !== null && b.priority !== null) {
|
||||
return a.priority - b.priority;
|
||||
}
|
||||
if (a.priority !== null) return -1;
|
||||
if (b.priority !== null) return 1;
|
||||
if (a.dueDate && b.dueDate) {
|
||||
return a.dueDate.getTime() - b.dueDate.getTime();
|
||||
}
|
||||
if (a.dueDate) return -1;
|
||||
if (b.dueDate) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const task of sortedTasks) {
|
||||
const slot = this.findNextAvailableSlot(
|
||||
blockedSlots,
|
||||
workingHours,
|
||||
task.estimatedDuration || 30,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
if (slot) {
|
||||
task.scheduledStart = slot.start;
|
||||
task.scheduledEnd = slot.end;
|
||||
await this.taskRepository.save(task);
|
||||
scheduledResults.push(task);
|
||||
|
||||
// Add this slot to blocked slots for next iteration
|
||||
blockedSlots.push(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Scheduled ${scheduledResults.length} tasks for user ${userId}`);
|
||||
|
||||
return { scheduledCount: scheduledResults.length, tasks: scheduledResults };
|
||||
}
|
||||
|
||||
private groupTasksByContext(tasks: Task[]): Record<string, Task[]> {
|
||||
const groups: Record<string, Task[]> = {};
|
||||
|
||||
for (const task of tasks) {
|
||||
const context = task.context || 'ANYWHERE';
|
||||
if (!groups[context]) {
|
||||
groups[context] = [];
|
||||
}
|
||||
groups[context].push(task);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
private findNextAvailableSlot(
|
||||
blockedSlots: TimeSlot[],
|
||||
workingHours: WorkingHours,
|
||||
durationMinutes: number,
|
||||
searchStart: Date,
|
||||
searchEnd: Date,
|
||||
): TimeSlot | null {
|
||||
let currentDate = startOfDay(searchStart);
|
||||
const maxIterations = 100;
|
||||
let iterations = 0;
|
||||
|
||||
while (isBefore(currentDate, searchEnd) && iterations < maxIterations) {
|
||||
iterations++;
|
||||
const dayOfWeek = getDay(currentDate);
|
||||
const dayName = DAY_NAMES[dayOfWeek];
|
||||
const dayHours = workingHours[dayName];
|
||||
|
||||
if (!dayHours) {
|
||||
currentDate = addDays(currentDate, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse working hours
|
||||
const [startHour, startMin] = dayHours.start.split(':').map(Number);
|
||||
const [endHour, endMin] = dayHours.end.split(':').map(Number);
|
||||
|
||||
let slotStart = setMinutes(setHours(currentDate, startHour), startMin);
|
||||
const dayEnd = setMinutes(setHours(currentDate, endHour), endMin);
|
||||
|
||||
// If searching from today, start from now if we're within working hours
|
||||
if (
|
||||
currentDate.toDateString() === searchStart.toDateString() &&
|
||||
isAfter(searchStart, slotStart)
|
||||
) {
|
||||
slotStart = this.roundToNext15Minutes(searchStart);
|
||||
}
|
||||
|
||||
// Try to find a slot in this day
|
||||
while (isBefore(slotStart, dayEnd)) {
|
||||
const slotEnd = addMinutes(slotStart, durationMinutes);
|
||||
|
||||
// Check if slot ends within working hours
|
||||
if (isAfter(slotEnd, dayEnd)) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if slot overlaps with any blocked slot
|
||||
const slot: TimeSlot = { start: slotStart, end: slotEnd };
|
||||
const hasConflict = blockedSlots.some((blocked) =>
|
||||
areIntervalsOverlapping(
|
||||
{ start: slot.start, end: slot.end },
|
||||
{ start: blocked.start, end: blocked.end },
|
||||
),
|
||||
);
|
||||
|
||||
if (!hasConflict) {
|
||||
// Check context constraints (e.g., work contexts during work hours)
|
||||
if (this.isValidContextSlot()) {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
|
||||
// Move to next 15-minute slot
|
||||
slotStart = addMinutes(slotStart, 15);
|
||||
}
|
||||
|
||||
// Move to next day
|
||||
currentDate = addDays(currentDate, 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private roundToNext15Minutes(date: Date): Date {
|
||||
const minutes = date.getMinutes();
|
||||
const remainder = minutes % 15;
|
||||
if (remainder === 0) {
|
||||
return date;
|
||||
}
|
||||
return addMinutes(date, 15 - remainder);
|
||||
}
|
||||
|
||||
private isValidContextSlot(): boolean {
|
||||
// All contexts are valid during working hours for now
|
||||
// Could add more specific rules later (e.g., phone calls only 9-5)
|
||||
return true;
|
||||
}
|
||||
|
||||
async clearSchedule(userId: string): Promise<number> {
|
||||
const result = await this.taskRepository.update(
|
||||
{ userId, isLocked: false, scheduledStart: Not(IsNull()) },
|
||||
{ scheduledStart: null, scheduledEnd: null },
|
||||
);
|
||||
return result.affected || 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user