feat: comprehensive update with Signal, Copilot, themes, and PDF features

## Signal Messaging Integration
- Added SignalService for sending messages to drivers via Signal
- SignalMessage model for tracking message history
- Driver chat modal for real-time messaging
- Send schedule via Signal (ICS + PDF attachments)

## AI Copilot
- Natural language interface for VIP Coordinator
- Capabilities: create VIPs, schedule events, assign drivers
- Help and guidance for users
- Floating copilot button in UI

## Theme System
- Dark/light/system theme support
- Color scheme selection (blue, green, purple, orange, red)
- ThemeContext for global state
- AppearanceMenu in header

## PDF Schedule Export
- VIPSchedulePDF component for schedule generation
- PDF settings (header, footer, branding)
- Preview PDF in browser
- Settings stored in database

## Database Migrations
- add_signal_messages: SignalMessage model
- add_pdf_settings: Settings model for PDF config
- add_reminder_tracking: lastReminderSent for events
- make_driver_phone_optional: phone field nullable

## Event Management
- Event status service for automated updates
- IN_PROGRESS/COMPLETED status tracking
- Reminder tracking for notifications

## UI/UX Improvements
- Driver schedule modal
- Improved My Schedule page
- Better error handling and loading states
- Responsive design improvements

## Other Changes
- AGENT_TEAM.md documentation
- Seed data improvements
- Ability factory updates
- Driver profile page

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-01 19:30:41 +01:00
parent 2d842ed294
commit 3b0b1205df
84 changed files with 12330 additions and 2103 deletions

View File

@@ -0,0 +1,327 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
interface SignalAccount {
number: string;
uuid: string;
username?: string;
}
export interface SignalStatus {
isConnected: boolean;
isLinked: boolean;
phoneNumber: string | null;
error?: string;
}
export interface QRCodeResponse {
qrcode: string;
expiresAt?: number;
}
@Injectable()
export class SignalService {
private readonly logger = new Logger(SignalService.name);
private readonly client: AxiosInstance;
private readonly baseUrl: string;
constructor() {
this.baseUrl = process.env.SIGNAL_API_URL || 'http://localhost:8080';
this.client = axios.create({
baseURL: this.baseUrl,
timeout: 30000,
});
}
/**
* Check if Signal API is available and get connection status
*/
async getStatus(): Promise<SignalStatus> {
try {
// Check if API is reachable
const response = await this.client.get('/v1/about');
// Try to get registered accounts
// API returns array of phone number strings: ["+1234567890"]
const accountsResponse = await this.client.get('/v1/accounts');
const accounts: string[] = accountsResponse.data;
if (accounts.length > 0) {
return {
isConnected: true,
isLinked: true,
phoneNumber: accounts[0],
};
}
return {
isConnected: true,
isLinked: false,
phoneNumber: null,
};
} catch (error: any) {
this.logger.error('Failed to connect to Signal API:', error.message);
return {
isConnected: false,
isLinked: false,
phoneNumber: null,
error: error.code === 'ECONNREFUSED'
? 'Signal API container is not running'
: error.message,
};
}
}
/**
* Get QR code for linking a new device
*/
async getQRCodeLink(deviceName: string = 'VIP Coordinator'): Promise<QRCodeResponse | null> {
try {
// First check if already linked
const status = await this.getStatus();
if (status.isLinked) {
this.logger.warn('Device already linked to Signal');
return null;
}
// Request QR code for device linking - returns raw PNG image
const response = await this.client.get('/v1/qrcodelink', {
params: { device_name: deviceName },
timeout: 60000, // QR generation can take a moment
responseType: 'arraybuffer', // Get raw binary data
});
// Convert to base64
const base64 = Buffer.from(response.data, 'binary').toString('base64');
return {
qrcode: base64,
};
} catch (error: any) {
this.logger.error('Failed to get QR code:', error.message);
throw error;
}
}
/**
* Register a new phone number (requires verification)
*/
async registerNumber(phoneNumber: string, captcha?: string): Promise<{ success: boolean; message: string }> {
try {
const response = await this.client.post(`/v1/register/${phoneNumber}`, {
captcha,
use_voice: false,
});
return {
success: true,
message: 'Verification code sent. Check your phone.',
};
} catch (error: any) {
this.logger.error('Failed to register number:', error.message);
return {
success: false,
message: error.response?.data?.error || error.message,
};
}
}
/**
* Verify a phone number with the code received
*/
async verifyNumber(phoneNumber: string, verificationCode: string): Promise<{ success: boolean; message: string }> {
try {
const response = await this.client.post(`/v1/register/${phoneNumber}/verify/${verificationCode}`);
return {
success: true,
message: 'Phone number verified and linked successfully!',
};
} catch (error: any) {
this.logger.error('Failed to verify number:', error.message);
return {
success: false,
message: error.response?.data?.error || error.message,
};
}
}
/**
* Unlink/unregister the current account
*/
async unlinkAccount(phoneNumber: string): Promise<{ success: boolean; message: string }> {
try {
await this.client.delete(`/v1/accounts/${phoneNumber}`);
return {
success: true,
message: 'Account unlinked successfully',
};
} catch (error: any) {
this.logger.error('Failed to unlink account:', error.message);
return {
success: false,
message: error.response?.data?.error || error.message,
};
}
}
/**
* Send a message to a recipient
*/
async sendMessage(
fromNumber: string,
toNumber: string,
message: string,
): Promise<{ success: boolean; timestamp?: number; error?: string }> {
try {
const response = await this.client.post(`/v2/send`, {
number: fromNumber,
recipients: [toNumber],
message,
});
this.logger.log(`Message sent to ${toNumber}`);
return {
success: true,
timestamp: response.data.timestamp,
};
} catch (error: any) {
this.logger.error(`Failed to send message to ${toNumber}:`, error.message);
return {
success: false,
error: error.response?.data?.error || error.message,
};
}
}
/**
* Send a message to multiple recipients
*/
async sendBulkMessage(
fromNumber: string,
toNumbers: string[],
message: string,
): Promise<{ success: boolean; sent: number; failed: number; errors: string[] }> {
const results = {
success: true,
sent: 0,
failed: 0,
errors: [] as string[],
};
for (const toNumber of toNumbers) {
const result = await this.sendMessage(fromNumber, toNumber, message);
if (result.success) {
results.sent++;
} else {
results.failed++;
results.errors.push(`${toNumber}: ${result.error}`);
}
}
results.success = results.failed === 0;
return results;
}
/**
* Get the linked phone number (if any)
*/
async getLinkedNumber(): Promise<string | null> {
try {
const response = await this.client.get('/v1/accounts');
// API returns array of phone number strings directly: ["+1234567890"]
const accounts: string[] = response.data;
if (accounts.length > 0) {
return accounts[0];
}
return null;
} catch (error) {
return null;
}
}
/**
* Format phone number for Signal (must include country code)
*/
formatPhoneNumber(phone: string): string {
// Remove all non-digit characters
let cleaned = phone.replace(/\D/g, '');
// Add US country code if not present
if (cleaned.length === 10) {
cleaned = '1' + cleaned;
}
// Add + prefix
if (!cleaned.startsWith('+')) {
cleaned = '+' + cleaned;
}
return cleaned;
}
/**
* Receive pending messages for the account
* This fetches and removes messages from Signal's queue
*/
async receiveMessages(phoneNumber: string): Promise<any[]> {
try {
const response = await this.client.get(`/v1/receive/${phoneNumber}`, {
timeout: 10000,
});
// Response is an array of message envelopes
return response.data || [];
} catch (error: any) {
// Don't log timeout errors or empty responses as errors
if (error.code === 'ECONNABORTED' || error.response?.status === 204) {
return [];
}
throw error;
}
}
/**
* Send a message with a file attachment (PDF, image, etc.)
* @param fromNumber - The sender's phone number
* @param toNumber - The recipient's phone number
* @param message - Optional text message to accompany the attachment
* @param attachment - Base64 encoded file data
* @param filename - Name for the file
* @param mimeType - MIME type of the file (e.g., 'application/pdf')
*/
async sendMessageWithAttachment(
fromNumber: string,
toNumber: string,
message: string,
attachment: string,
filename: string,
mimeType: string = 'application/pdf',
): Promise<{ success: boolean; timestamp?: number; error?: string }> {
try {
// Format: data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>
const base64Attachment = `data:${mimeType};filename=${filename};base64,${attachment}`;
const response = await this.client.post(`/v2/send`, {
number: fromNumber,
recipients: [toNumber],
message: message || '',
base64_attachments: [base64Attachment],
});
this.logger.log(`Message with attachment sent to ${toNumber}: ${filename}`);
return {
success: true,
timestamp: response.data.timestamp,
};
} catch (error: any) {
this.logger.error(`Failed to send attachment to ${toNumber}:`, error.message);
return {
success: false,
error: error.response?.data?.error || error.message,
};
}
}
}