Files
stack/apps/api/src/websocket/websocket.gateway.ts
Jason Woltje 5048d9eb01 feat(#115,#116): implement cron scheduler worker and WebSocket notifications
## Issues Addressed
- #115: Cron scheduler worker
- #116: Cron WebSocket notifications

## Changes

### CronSchedulerService (cron.scheduler.ts)
- Polls CronSchedule table every minute for due schedules
- Executes commands when schedules fire (placeholder for MoltBot integration)
- Updates lastRun/nextRun fields after execution
- Handles errors gracefully with logging
- Supports manual trigger for testing
- Start/stop lifecycle management

### WebSocket Integration
- Added emitCronExecuted() method to WebSocketGateway
- Emits workspace-scoped cron:executed events
- Payload includes: scheduleId, command, executedAt

### Tests
- cron.scheduler.spec.ts: 9 passing tests
- Tests cover: status, due schedule processing, manual trigger, scheduler lifecycle

## Technical Notes
- Placeholder triggerMoltBotCommand() needs actual implementation
- Uses setInterval for polling (could upgrade to cron-parser library)
- WebSocket rooms use workspace:{id} format (existing pattern)

## Files Changed
- apps/api/src/cron/cron.scheduler.ts (new)
- apps/api/src/cron/cron.scheduler.spec.ts (new)
- apps/api/src/cron/cron.module.ts (updated)
- apps/api/src/websocket/websocket.gateway.ts (updated)
2026-01-29 23:05:39 -06:00

181 lines
5.0 KiB
TypeScript

import {
WebSocketGateway as WSGateway,
WebSocketServer,
OnGatewayConnection,
OnGatewayDisconnect,
} from '@nestjs/websockets';
import { Logger } from '@nestjs/common';
import { Server, Socket } from 'socket.io';
interface AuthenticatedSocket extends Socket {
data: {
userId?: string;
workspaceId?: string;
};
}
interface Task {
id: string;
workspaceId: string;
[key: string]: unknown;
}
interface Event {
id: string;
workspaceId: string;
[key: string]: unknown;
}
interface Project {
id: string;
workspaceId: string;
[key: string]: unknown;
}
/**
* WebSocket Gateway for real-time updates
* Handles workspace-scoped rooms for broadcasting events
*/
@WSGateway({
cors: {
origin: process.env.WEB_URL || 'http://localhost:3000',
credentials: true,
},
})
export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnect {
@WebSocketServer()
server!: Server;
private readonly logger = new Logger(WebSocketGateway.name);
/**
* Handle client connection
* Joins client to workspace-specific room
*/
async handleConnection(client: AuthenticatedSocket): Promise<void> {
const { userId, workspaceId } = client.data;
if (!userId || !workspaceId) {
this.logger.warn(`Client ${client.id} connected without authentication`);
client.disconnect();
return;
}
const room = this.getWorkspaceRoom(workspaceId);
await client.join(room);
this.logger.log(`Client ${client.id} joined room ${room}`);
}
/**
* Handle client disconnect
* Leaves workspace room
*/
handleDisconnect(client: AuthenticatedSocket): void {
const { workspaceId } = client.data;
if (workspaceId) {
const room = this.getWorkspaceRoom(workspaceId);
client.leave(room);
this.logger.log(`Client ${client.id} left room ${room}`);
}
}
/**
* Emit task:created event to workspace room
*/
emitTaskCreated(workspaceId: string, task: Task): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('task:created', task);
this.logger.debug(`Emitted task:created to ${room}`);
}
/**
* Emit task:updated event to workspace room
*/
emitTaskUpdated(workspaceId: string, task: Task): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('task:updated', task);
this.logger.debug(`Emitted task:updated to ${room}`);
}
/**
* Emit task:deleted event to workspace room
*/
emitTaskDeleted(workspaceId: string, taskId: string): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('task:deleted', { id: taskId });
this.logger.debug(`Emitted task:deleted to ${room}`);
}
/**
* Emit event:created event to workspace room
*/
emitEventCreated(workspaceId: string, event: Event): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('event:created', event);
this.logger.debug(`Emitted event:created to ${room}`);
}
/**
* Emit event:updated event to workspace room
*/
emitEventUpdated(workspaceId: string, event: Event): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('event:updated', event);
this.logger.debug(`Emitted event:updated to ${room}`);
}
/**
* Emit event:deleted event to workspace room
*/
emitEventDeleted(workspaceId: string, eventId: string): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('event:deleted', { id: eventId });
this.logger.debug(`Emitted event:deleted to ${room}`);
}
/**
* Emit project:created event to workspace room
*/
emitProjectCreated(workspaceId: string, project: Project): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('project:created', project);
this.logger.debug(`Emitted project:created to ${room}`);
}
/**
* Emit project:updated event to workspace room
*/
emitProjectUpdated(workspaceId: string, project: Project): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('project:updated', project);
this.logger.debug(`Emitted project:updated to ${room}`);
}
/**
* Emit project:deleted event to workspace room
*/
emitProjectDeleted(workspaceId: string, projectId: string): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('project:deleted', { id: projectId });
this.logger.debug(`Emitted project:deleted to ${room}`);
}
/**
* Emit cron:executed event when a scheduled command fires
*/
emitCronExecuted(workspaceId: string, data: { scheduleId: string; command: string; executedAt: Date }): void {
const room = this.getWorkspaceRoom(workspaceId);
this.server.to(room).emit('cron:executed', data);
this.logger.debug(`Emitted cron:executed to ${room}`);
}
/**
* Get workspace room name
*/
private getWorkspaceRoom(workspaceId: string): string {
return `workspace:${workspaceId}`;
}
}