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:
Debian
2026-01-11 09:58:15 +00:00
parent e7ffcce768
commit 9c6b85f28a
29 changed files with 1863 additions and 47 deletions

View File

@@ -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 {}

View 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));
}
}

View 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 {}

View 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(),
};
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 };
}
}

View File

@@ -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 {}

View 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(),
};
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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',

View File

@@ -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);

View 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 };
}
}

View 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 {}

View 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;
}
}