feat: QuickBooks Online integration
- Add QBO OAuth2 client with token refresh (lib/services/qbo-client.ts) - Add QBO sync service for invoices, payments, deposits, purchases, journal entries, reports (lib/services/qbo-sync-service.ts) - Add QBO types (lib/types/qbo.ts) - Add API routes: /api/qbo/auth, /api/qbo/sync, /api/qbo/disconnect - Add /admin/qbo status and sync management page - Add legal pages: /legal/eula, /legal/privacy (Intuit app assessment) - Add QBO nav link under Admin - Fix reports: remove invalid summarize_column_by, add accounting_method from Preferences API, add showrows=all&showcols=all - Add CashFlow report type alongside P&L and BalanceSheet - Add NoReportData check to skip empty report months - Add intuit_tid capture in error messages - Add redirect: follow for cluster routing - Migration 051: qbo_tokens, qbo_invoices, qbo_payments, qbo_deposits, qbo_transactions, qbo_reports tables Also includes earlier work: - Ping flap suppression pipeline step - Ticket digest reports with LLM analysis - Zabbix WAN monitor and gap analysis - Kiosk is_deleted filter fixes - Datto RMM ping target enrichment - Entity sync soft-delete detection
This commit is contained in:
parent
c518eefdb2
commit
b98c67482a
40 changed files with 6223 additions and 15 deletions
|
|
@ -26,6 +26,10 @@ function getWebhookEntityName(entityType: WebhookEntityType): string {
|
|||
ConfigurationItems: 'ConfigurationItemWebhooks',
|
||||
Tickets: 'TicketWebhooks',
|
||||
TicketNotes: 'TicketNoteWebhooks',
|
||||
TimeEntries: 'TimeEntryWebhooks',
|
||||
Tasks: 'TaskWebhooks',
|
||||
Projects: 'ProjectWebhooks',
|
||||
Contracts: 'ContractWebhooks',
|
||||
};
|
||||
return map[entityType] || `${entityType}Webhooks`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -551,4 +551,28 @@ export class DattoRMMClient {
|
|||
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* For a PING alert, fetch the ping target (instanceName) from alertContext.
|
||||
* Returns null if not found or API call fails.
|
||||
*/
|
||||
async getPingAlertTarget(alertUid: string): Promise<string | null> {
|
||||
const token = await this.getAccessToken();
|
||||
const url = `${this.config.apiUrl}/api/v2/alert/${alertUid}`;
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) return null;
|
||||
|
||||
const data = await resp.json();
|
||||
const ctx = data?.alertContext;
|
||||
if (ctx?.['@class'] === 'ping_ctx' && ctx?.instanceName) {
|
||||
return ctx.instanceName as string;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -386,13 +386,15 @@ export class EntitySyncService {
|
|||
|
||||
// For full sync, soft delete records not in the fetched set
|
||||
// IMPORTANT: Skip soft deletes if ANY filters were applied (date, status, active, etc.)
|
||||
// because we cannot know what records exist outside the filter criteria
|
||||
// because we cannot know what records exist outside the filter criteria.
|
||||
// EXCEPTION: TICKETS — we do a separate ID-only fetch from Autotask (no filters) to detect
|
||||
// deletions even when date filters were applied to the main sync.
|
||||
let deletedCount = 0;
|
||||
|
||||
|
||||
if (!isIncremental && !hasAppliedFilters) {
|
||||
entityLogger.phase(SyncPhase.DELETING, 'Checking for records to soft delete');
|
||||
syncProgressTracker.updateProgress(trackingId, { phase: 'deleting' });
|
||||
|
||||
|
||||
try {
|
||||
const activeIds = mappedRecords.map(r => r.id);
|
||||
deletedCount = await softDeleteMissingRecords(entity, activeIds);
|
||||
|
|
@ -400,7 +402,43 @@ export class EntitySyncService {
|
|||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
entityLogger.warn('Soft delete failed, continuing sync', {}, err);
|
||||
// Don't throw - soft delete failure shouldn't fail the entire sync
|
||||
}
|
||||
} else if (!isIncremental && hasAppliedFilters && entity === EntityType.TICKETS) {
|
||||
entityLogger.phase(SyncPhase.DELETING, 'Fetching all Autotask ticket IDs for deletion diff');
|
||||
syncProgressTracker.updateProgress(trackingId, { phase: 'deleting' });
|
||||
|
||||
try {
|
||||
// Fetch all ticket IDs from Autotask with no filters for deletion diff
|
||||
const allAutotaskTickets = await this.autotaskClient.queryEntityPaginated(
|
||||
'Tickets',
|
||||
{},
|
||||
500
|
||||
);
|
||||
const liveIds = new Set(allAutotaskTickets.map((t: any) => String(t.id)));
|
||||
|
||||
// Find Pulse ticket IDs not present in Autotask → these were deleted
|
||||
const pulseResult = await postgresClient.query(
|
||||
`SELECT id FROM tickets WHERE is_deleted = false`
|
||||
);
|
||||
const toDelete = pulseResult.rows
|
||||
.map((r: { id: number }) => r.id)
|
||||
.filter((id: number) => !liveIds.has(String(id)));
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
const result = await postgresClient.query(
|
||||
`UPDATE tickets
|
||||
SET is_deleted = true, deleted_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ANY($1::int[])`,
|
||||
[toDelete]
|
||||
);
|
||||
deletedCount = result.rowCount || 0;
|
||||
entityLogger.info('Soft deleted tickets missing from Autotask', { deletedCount });
|
||||
} else {
|
||||
entityLogger.info('No deleted tickets detected');
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
entityLogger.warn('Ticket deletion diff failed, continuing sync', {}, err);
|
||||
}
|
||||
} else if (!isIncremental && hasAppliedFilters) {
|
||||
entityLogger.info('Skipping soft-delete because filters were applied (would delete records outside filter criteria)');
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ import './rmm-quick-job';
|
|||
import './enrich-vspc';
|
||||
import './db-query';
|
||||
import './fetch-b2-result';
|
||||
import './ping-flap-suppress';
|
||||
|
|
|
|||
142
lib/services/pipeline-steps/ping-flap-suppress.ts
Normal file
142
lib/services/pipeline-steps/ping-flap-suppress.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { registerStepExecutor } from '../pipeline-engine';
|
||||
import { PipelineStep, PipelineContext, StepExecutorResult } from '../../types/pipeline';
|
||||
import { postgresClient } from '../postgres-client';
|
||||
import { ZabbixClient } from '../zabbix-client';
|
||||
import { ZabbixProblem } from '../../types/zabbix';
|
||||
import { promises as dns } from 'dns';
|
||||
|
||||
/**
|
||||
* Ping Flap Suppression Step
|
||||
*
|
||||
* Detects flapping PING alerts from Datto RMM and suppresses the corresponding
|
||||
* Zabbix problem when a threshold is exceeded.
|
||||
*
|
||||
* Flap = same ping_target triggers >= FLAP_THRESHOLD times within FLAP_WINDOW_HOURS.
|
||||
* When detected:
|
||||
* - Suppresses the open Zabbix problem for the matching host for SUPPRESS_HOURS
|
||||
* - Records the suppression in ping_flap_suppressions to avoid re-firing
|
||||
*/
|
||||
|
||||
const FLAP_THRESHOLD = 5;
|
||||
const FLAP_WINDOW_HOURS = 2;
|
||||
const SUPPRESS_HOURS = 4;
|
||||
|
||||
function makeZabbix(): ZabbixClient {
|
||||
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
|
||||
throw new Error('Zabbix not configured');
|
||||
}
|
||||
return new ZabbixClient({ apiUrl: process.env.ZABBIX_API_URL, apiToken: process.env.ZABBIX_API_TOKEN });
|
||||
}
|
||||
|
||||
async function executePingFlapSuppress(
|
||||
_step: PipelineStep,
|
||||
context: PipelineContext,
|
||||
_executionId: number
|
||||
): Promise<StepExecutorResult> {
|
||||
const payload = context.triggerData;
|
||||
|
||||
if (payload?.alert_type !== 'PING') {
|
||||
return { success: true, output: { skipped: true } };
|
||||
}
|
||||
|
||||
const pingTarget: string | null = payload?.ping_target ?? null;
|
||||
if (!pingTarget) {
|
||||
return { success: true, output: { skipped: true, reason: 'no ping_target' } };
|
||||
}
|
||||
|
||||
// Check if already suppressed
|
||||
const suppCheck = await postgresClient.query(
|
||||
`SELECT suppressed_until FROM ping_flap_suppressions
|
||||
WHERE ping_target = $1 AND suppressed_until > NOW()`,
|
||||
[pingTarget]
|
||||
);
|
||||
if (suppCheck.rows.length > 0) {
|
||||
return { success: true, output: { flap_suppressed: true, already_active: true } };
|
||||
}
|
||||
|
||||
// Count triggers in the flap window
|
||||
const countResult = await postgresClient.query(
|
||||
`SELECT COUNT(*) as cnt FROM datto_rmm_alerts
|
||||
WHERE alert_type = 'PING'
|
||||
AND ping_target = $1
|
||||
AND triggered = 'True'
|
||||
AND timestamp >= NOW() - INTERVAL '${FLAP_WINDOW_HOURS} hours'`,
|
||||
[pingTarget]
|
||||
);
|
||||
const triggerCount = parseInt(countResult.rows[0]?.cnt ?? '0', 10);
|
||||
|
||||
if (triggerCount < FLAP_THRESHOLD) {
|
||||
return { success: true, output: { flap_detected: false, trigger_count: triggerCount } };
|
||||
}
|
||||
|
||||
const suppressUntil = new Date(Date.now() + SUPPRESS_HOURS * 60 * 60 * 1000);
|
||||
const message = `Auto-suppressed: flapping detected (${triggerCount} triggers in ${FLAP_WINDOW_HOURS}h). Awaiting manual resolution.`;
|
||||
|
||||
// Try to find and suppress the Zabbix problem
|
||||
let zabbixSuppressed = false;
|
||||
let zabbixHost: string | null = null;
|
||||
try {
|
||||
const zabbix = makeZabbix();
|
||||
|
||||
// Resolve DNS name to IP — Zabbix stores IPs in interfaces, not DNS names
|
||||
const lookupTargets = [pingTarget];
|
||||
try {
|
||||
const resolved = await dns.lookup(pingTarget);
|
||||
if (resolved.address && resolved.address !== pingTarget) {
|
||||
lookupTargets.unshift(resolved.address);
|
||||
}
|
||||
} catch { /* not a resolvable hostname — may already be an IP */ }
|
||||
|
||||
let hosts: Array<{ hostid: string; name: string }> = [];
|
||||
for (const target of lookupTargets) {
|
||||
hosts = await zabbix.findHostsByInterface(target);
|
||||
if (hosts.length > 0) break;
|
||||
}
|
||||
|
||||
if (hosts.length > 0) {
|
||||
const host = hosts[0];
|
||||
zabbixHost = host.name;
|
||||
const problems = await zabbix.getOpenProblemsForHost(host.hostid);
|
||||
const unreachable = problems.find((p: ZabbixProblem) =>
|
||||
p.name.toLowerCase().includes('unreachable') ||
|
||||
p.name.toLowerCase().includes('unavailable')
|
||||
);
|
||||
|
||||
if (unreachable) {
|
||||
await zabbix.suppressProblem(unreachable.eventid, suppressUntil, message);
|
||||
zabbixSuppressed = true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[ping-flap-suppress] Zabbix error for ${pingTarget}:`, err);
|
||||
}
|
||||
|
||||
// Record suppression to prevent repeated attempts within the window
|
||||
await postgresClient.query(
|
||||
`INSERT INTO ping_flap_suppressions (ping_target, trigger_count, suppressed_until, zabbix_suppressed, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (ping_target) DO UPDATE SET
|
||||
trigger_count = EXCLUDED.trigger_count,
|
||||
suppressed_until = EXCLUDED.suppressed_until,
|
||||
zabbix_suppressed = EXCLUDED.zabbix_suppressed,
|
||||
notes = EXCLUDED.notes,
|
||||
updated_at = NOW()`,
|
||||
[pingTarget, triggerCount, suppressUntil, zabbixSuppressed, message]
|
||||
);
|
||||
|
||||
console.log(`[ping-flap-suppress] Flap detected: ${pingTarget} (${triggerCount} triggers) — zabbix_suppressed=${zabbixSuppressed} host=${zabbixHost}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
flap_detected: true,
|
||||
flap_suppressed: true,
|
||||
trigger_count: triggerCount,
|
||||
zabbix_suppressed: zabbixSuppressed,
|
||||
zabbix_host: zabbixHost,
|
||||
suppressed_until: suppressUntil.toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
registerStepExecutor('ping_flap_suppress', executePingFlapSuppress);
|
||||
290
lib/services/qbo-client.ts
Normal file
290
lib/services/qbo-client.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
/**
|
||||
* QuickBooks Online API Client
|
||||
* Handles OAuth2 token management and all QBO REST API calls.
|
||||
*/
|
||||
|
||||
import postgresClient from './postgres-client';
|
||||
import {
|
||||
QboTokenRecord,
|
||||
QboTokenResponse,
|
||||
QboInvoice,
|
||||
QboPayment,
|
||||
QboDeposit,
|
||||
QboPurchase,
|
||||
QboJournalEntry,
|
||||
QboReport,
|
||||
QboQueryResponse,
|
||||
} from '@/lib/types/qbo';
|
||||
|
||||
const QBO_PRODUCTION_URL = 'https://quickbooks.api.intuit.com';
|
||||
const QBO_SANDBOX_URL = 'https://sandbox-quickbooks.api.intuit.com';
|
||||
const QBO_TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer';
|
||||
|
||||
export class QboClient {
|
||||
private clientId: string;
|
||||
private clientSecret: string;
|
||||
private realmId: string;
|
||||
private baseUrl: string;
|
||||
private _reportBasis: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.clientId = process.env.QBO_CLIENT_ID || '';
|
||||
this.clientSecret = process.env.QBO_CLIENT_SECRET || '';
|
||||
this.realmId = process.env.QBO_REALM_ID || '';
|
||||
this.baseUrl = process.env.QBO_SANDBOX === 'true' ? QBO_SANDBOX_URL : QBO_PRODUCTION_URL;
|
||||
|
||||
if (!this.clientId || !this.clientSecret || !this.realmId) {
|
||||
throw new Error('QBO_CLIENT_ID, QBO_CLIENT_SECRET, and QBO_REALM_ID must be set');
|
||||
}
|
||||
|
||||
console.log(`[QboClient] Using ${process.env.QBO_SANDBOX === 'true' ? 'SANDBOX' : 'PRODUCTION'} environment`);
|
||||
}
|
||||
|
||||
// ─── Token Management ───────────────────────────────────────────────────────
|
||||
|
||||
async getValidAccessToken(): Promise<string> {
|
||||
const token = await this.loadToken();
|
||||
if (!token) {
|
||||
throw new Error('No QBO token found. Complete OAuth2 authorization first via /api/qbo/auth');
|
||||
}
|
||||
|
||||
if (new Date() < new Date(token.access_token_expires_at)) {
|
||||
return token.access_token;
|
||||
}
|
||||
|
||||
if (new Date() >= new Date(token.refresh_token_expires_at)) {
|
||||
throw new Error('QBO refresh token has expired. Re-authorization required via /api/qbo/auth');
|
||||
}
|
||||
|
||||
return this.refreshAccessToken(token.refresh_token);
|
||||
}
|
||||
|
||||
async loadToken(): Promise<QboTokenRecord | null> {
|
||||
const result = await postgresClient.query<QboTokenRecord>(
|
||||
`SELECT * FROM qbo_tokens WHERE realm_id = $1 LIMIT 1`,
|
||||
[this.realmId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async saveToken(token: QboTokenResponse): Promise<void> {
|
||||
const now = new Date();
|
||||
const accessExpiry = new Date(now.getTime() + token.expires_in * 1000);
|
||||
const refreshExpiry = new Date(now.getTime() + token.x_refresh_token_expires_in * 1000);
|
||||
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_tokens (realm_id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (realm_id) DO UPDATE SET
|
||||
access_token = EXCLUDED.access_token,
|
||||
refresh_token = EXCLUDED.refresh_token,
|
||||
access_token_expires_at = EXCLUDED.access_token_expires_at,
|
||||
refresh_token_expires_at = EXCLUDED.refresh_token_expires_at,
|
||||
updated_at = NOW()`,
|
||||
[this.realmId, token.access_token, token.refresh_token, accessExpiry, refreshExpiry]
|
||||
);
|
||||
}
|
||||
|
||||
async exchangeCodeForToken(authCode: string, redirectUri: string): Promise<QboTokenResponse> {
|
||||
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: authCode,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
const res = await fetch(QBO_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`QBO token exchange failed: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
const tokenData: QboTokenResponse = await res.json();
|
||||
await this.saveToken(tokenData);
|
||||
return tokenData;
|
||||
}
|
||||
|
||||
private async refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64');
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const res = await fetch(QBO_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`QBO token refresh failed: ${res.status} ${err}`);
|
||||
}
|
||||
|
||||
const tokenData: QboTokenResponse = await res.json();
|
||||
await this.saveToken(tokenData);
|
||||
console.log('[QboClient] Access token refreshed successfully');
|
||||
return tokenData.access_token;
|
||||
}
|
||||
|
||||
getAuthorizationUrl(redirectUri: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: this.clientId,
|
||||
scope: 'com.intuit.quickbooks.accounting',
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
state,
|
||||
});
|
||||
return `https://appcenter.intuit.com/connect/oauth2?${params.toString()}`;
|
||||
}
|
||||
|
||||
// ─── Core API Request ────────────────────────────────────────────────────────
|
||||
|
||||
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const accessToken = await this.getValidAccessToken();
|
||||
const url = `${this.baseUrl}/v3/company/${this.realmId}${path}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
redirect: 'follow',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
const intuitTid = res.headers.get('intuit_tid') || res.headers.get('intuit-tid') || 'unknown';
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
throw new Error(`QBO API error ${res.status} on ${path} [intuit_tid=${intuitTid}]: ${err}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─── Paginated Query ─────────────────────────────────────────────────────────
|
||||
|
||||
private async queryAll<T>(entity: string, extraWhere = ''): Promise<T[]> {
|
||||
const all: T[] = [];
|
||||
let startPos = 1;
|
||||
const pageSize = 1000;
|
||||
|
||||
while (true) {
|
||||
const where = extraWhere ? ` WHERE ${extraWhere}` : '';
|
||||
const sql = encodeURIComponent(
|
||||
`SELECT * FROM ${entity}${where} STARTPOSITION ${startPos} MAXRESULTS ${pageSize}`
|
||||
);
|
||||
const data = await this.request<QboQueryResponse<T>>(`/query?query=${sql}&minorversion=65`);
|
||||
const items: T[] = (data.QueryResponse[entity] as T[]) || [];
|
||||
all.push(...items);
|
||||
|
||||
if (items.length < pageSize) break;
|
||||
startPos += pageSize;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
// ─── Entity Fetchers ─────────────────────────────────────────────────────────
|
||||
|
||||
async getInvoices(updatedSince?: Date): Promise<QboInvoice[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboInvoice>('Invoice', where);
|
||||
}
|
||||
|
||||
async getPayments(updatedSince?: Date): Promise<QboPayment[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboPayment>('Payment', where);
|
||||
}
|
||||
|
||||
async getDeposits(updatedSince?: Date): Promise<QboDeposit[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboDeposit>('Deposit', where);
|
||||
}
|
||||
|
||||
async getPurchases(updatedSince?: Date): Promise<QboPurchase[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboPurchase>('Purchase', where);
|
||||
}
|
||||
|
||||
async getJournalEntries(updatedSince?: Date): Promise<QboJournalEntry[]> {
|
||||
const where = updatedSince
|
||||
? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'`
|
||||
: undefined;
|
||||
return this.queryAll<QboJournalEntry>('JournalEntry', where);
|
||||
}
|
||||
|
||||
async getPreferences(): Promise<{ reportBasis: string }> {
|
||||
if (this._reportBasis) return { reportBasis: this._reportBasis };
|
||||
const data = await this.request<{ Preferences?: { ReportPrefs?: { ReportBasis?: string } } }>('/preferences?minorversion=65');
|
||||
this._reportBasis = data?.Preferences?.ReportPrefs?.ReportBasis ?? 'Accrual';
|
||||
return { reportBasis: this._reportBasis };
|
||||
}
|
||||
|
||||
async getProfitAndLoss(startDate: string, endDate: string, accountingMethod?: string): Promise<QboReport> {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
accounting_method: accountingMethod ?? 'Accrual',
|
||||
showrows: 'all',
|
||||
showcols: 'all',
|
||||
minorversion: '65',
|
||||
});
|
||||
return this.request<QboReport>(`/reports/ProfitAndLoss?${params.toString()}`);
|
||||
}
|
||||
|
||||
async getBalanceSheet(startDate: string, endDate: string, accountingMethod?: string): Promise<QboReport> {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
accounting_method: accountingMethod ?? 'Accrual',
|
||||
showrows: 'all',
|
||||
showcols: 'all',
|
||||
minorversion: '65',
|
||||
});
|
||||
return this.request<QboReport>(`/reports/BalanceSheet?${params.toString()}`);
|
||||
}
|
||||
|
||||
async getCashFlow(startDate: string, endDate: string): Promise<QboReport> {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
minorversion: '65',
|
||||
});
|
||||
return this.request<QboReport>(`/reports/CashFlow?${params.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: QboClient | null = null;
|
||||
|
||||
export function getQboClient(): QboClient {
|
||||
if (!_instance) {
|
||||
_instance = new QboClient();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
522
lib/services/qbo-sync-service.ts
Normal file
522
lib/services/qbo-sync-service.ts
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
/**
|
||||
* QuickBooks Online Sync Service
|
||||
* Fetches invoices, payments, deposits, transactions, and financial reports
|
||||
* from QBO and upserts them into PostgreSQL.
|
||||
*/
|
||||
|
||||
import postgresClient from './postgres-client';
|
||||
import { QboClient, getQboClient } from './qbo-client';
|
||||
import {
|
||||
QboInvoice,
|
||||
QboPayment,
|
||||
QboDeposit,
|
||||
QboPurchase,
|
||||
QboJournalEntry,
|
||||
QboReport,
|
||||
QboSyncResult,
|
||||
QboEntitySyncResult,
|
||||
} from '@/lib/types/qbo';
|
||||
|
||||
export class QboSyncService {
|
||||
private client: QboClient;
|
||||
private isSyncing = false;
|
||||
|
||||
constructor(client?: QboClient) {
|
||||
this.client = client || getQboClient();
|
||||
}
|
||||
|
||||
isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
|
||||
async fullSync(triggeredBy = 'system'): Promise<QboSyncResult> {
|
||||
return this.executeSync('full', triggeredBy);
|
||||
}
|
||||
|
||||
async incrementalSync(triggeredBy = 'system'): Promise<QboSyncResult> {
|
||||
return this.executeSync('incremental', triggeredBy);
|
||||
}
|
||||
|
||||
private async executeSync(
|
||||
syncType: 'full' | 'incremental',
|
||||
triggeredBy: string
|
||||
): Promise<QboSyncResult> {
|
||||
if (this.isSyncing) {
|
||||
throw new Error('QBO sync already in progress');
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
const syncId = `qbo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const startedAt = new Date();
|
||||
const entities: QboEntitySyncResult[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const realmId = process.env.QBO_REALM_ID || '';
|
||||
|
||||
console.log(`[QboSync] Starting ${syncType} sync (${syncId}) triggered by ${triggeredBy}`);
|
||||
|
||||
try {
|
||||
// Determine updatedSince for incremental
|
||||
let updatedSince: Date | undefined;
|
||||
if (syncType === 'incremental') {
|
||||
updatedSince = await this.getLastSyncTime();
|
||||
}
|
||||
|
||||
// Sync invoices
|
||||
entities.push(await this.syncInvoices(realmId, updatedSince));
|
||||
|
||||
// Sync payments
|
||||
entities.push(await this.syncPayments(realmId, updatedSince));
|
||||
|
||||
// Sync deposits
|
||||
entities.push(await this.syncDeposits(realmId, updatedSince));
|
||||
|
||||
// Sync purchases (expenses/credit card charges)
|
||||
entities.push(await this.syncPurchases(realmId, updatedSince));
|
||||
|
||||
// Sync journal entries
|
||||
entities.push(await this.syncJournalEntries(realmId, updatedSince));
|
||||
|
||||
// Sync reports — always fetch current + last 12 months on full, current month on incremental
|
||||
if (syncType === 'full') {
|
||||
entities.push(await this.syncReports(realmId, 12));
|
||||
} else {
|
||||
entities.push(await this.syncReports(realmId, 1));
|
||||
}
|
||||
|
||||
// Record sync history
|
||||
await this.recordSyncHistory(syncId, syncType, triggeredBy, startedAt, 'completed', entities);
|
||||
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(msg);
|
||||
console.error(`[QboSync] Sync failed: ${msg}`);
|
||||
await this.recordSyncHistory(syncId, syncType, triggeredBy, startedAt, 'failed', entities, msg);
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
}
|
||||
|
||||
const completedAt = new Date();
|
||||
return {
|
||||
syncId,
|
||||
realmId,
|
||||
status: errors.length === 0 ? 'completed' : 'failed',
|
||||
startedAt,
|
||||
completedAt,
|
||||
duration: completedAt.getTime() - startedAt.getTime(),
|
||||
entities,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Entity Sync Methods ───────────────────────────────────────────────────
|
||||
|
||||
private async syncInvoices(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const invoices = await this.client.getInvoices(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${invoices.length} invoices`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const inv of invoices) {
|
||||
const status = this.deriveInvoiceStatus(inv);
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_invoices
|
||||
(id, realm_id, doc_number, txn_date, due_date, customer_ref_id, customer_ref_name,
|
||||
email_address, total_amt, balance, status, currency_code, line_items, linked_txns,
|
||||
sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
doc_number = EXCLUDED.doc_number,
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
due_date = EXCLUDED.due_date,
|
||||
customer_ref_id = EXCLUDED.customer_ref_id,
|
||||
customer_ref_name = EXCLUDED.customer_ref_name,
|
||||
email_address = EXCLUDED.email_address,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
balance = EXCLUDED.balance,
|
||||
status = EXCLUDED.status,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
line_items = EXCLUDED.line_items,
|
||||
linked_txns = EXCLUDED.linked_txns,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
inv.Id, realmId, inv.DocNumber ?? null,
|
||||
inv.TxnDate ?? null, inv.DueDate ?? null,
|
||||
inv.CustomerRef?.value ?? null, inv.CustomerRef?.name ?? null,
|
||||
inv.BillEmail?.Address ?? null,
|
||||
inv.TotalAmt ?? null, inv.Balance ?? null,
|
||||
status, inv.CurrencyRef?.value ?? 'USD',
|
||||
JSON.stringify(inv.Line ?? []),
|
||||
JSON.stringify(inv.LinkedTxn ?? []),
|
||||
inv.SyncToken,
|
||||
inv.MetaData?.CreateTime ?? null,
|
||||
inv.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'invoices', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Invoice sync failed: ${msg}`);
|
||||
return { entity: 'invoices', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncPayments(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const payments = await this.client.getPayments(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${payments.length} payments`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const pay of payments) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_payments
|
||||
(id, realm_id, txn_date, customer_ref_id, customer_ref_name, total_amt, unapplied_amt,
|
||||
currency_code, payment_method_ref, deposit_account_ref, linked_txns,
|
||||
sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
customer_ref_id = EXCLUDED.customer_ref_id,
|
||||
customer_ref_name = EXCLUDED.customer_ref_name,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
unapplied_amt = EXCLUDED.unapplied_amt,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
payment_method_ref = EXCLUDED.payment_method_ref,
|
||||
deposit_account_ref = EXCLUDED.deposit_account_ref,
|
||||
linked_txns = EXCLUDED.linked_txns,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
pay.Id, realmId, pay.TxnDate ?? null,
|
||||
pay.CustomerRef?.value ?? null, pay.CustomerRef?.name ?? null,
|
||||
pay.TotalAmt ?? null, pay.UnappliedAmt ?? null,
|
||||
pay.CurrencyRef?.value ?? 'USD',
|
||||
pay.PaymentMethodRef?.name ?? null,
|
||||
pay.DepositToAccountRef?.name ?? null,
|
||||
JSON.stringify(pay.Line ?? []),
|
||||
pay.SyncToken,
|
||||
pay.MetaData?.CreateTime ?? null,
|
||||
pay.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'payments', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Payment sync failed: ${msg}`);
|
||||
return { entity: 'payments', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncDeposits(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const deposits = await this.client.getDeposits(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${deposits.length} deposits`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const dep of deposits) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_deposits
|
||||
(id, realm_id, txn_date, deposit_to_account_ref_id, deposit_to_account_ref_name,
|
||||
total_amt, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW())
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
deposit_to_account_ref_id = EXCLUDED.deposit_to_account_ref_id,
|
||||
deposit_to_account_ref_name = EXCLUDED.deposit_to_account_ref_name,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
line_items = EXCLUDED.line_items,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
dep.Id, realmId, dep.TxnDate ?? null,
|
||||
dep.DepositToAccountRef?.value ?? null,
|
||||
dep.DepositToAccountRef?.name ?? null,
|
||||
dep.TotalAmt ?? null,
|
||||
JSON.stringify(dep.Line ?? []),
|
||||
dep.SyncToken,
|
||||
dep.MetaData?.CreateTime ?? null,
|
||||
dep.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'deposits', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Deposit sync failed: ${msg}`);
|
||||
return { entity: 'deposits', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncPurchases(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const purchases = await this.client.getPurchases(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${purchases.length} purchases`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const p of purchases) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_transactions
|
||||
(id, txn_type, realm_id, txn_date, doc_number, entity_ref_id, entity_ref_name,
|
||||
entity_type, account_ref_id, account_ref_name, total_amt, currency_code,
|
||||
private_note, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,'Purchase',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,NOW())
|
||||
ON CONFLICT (id, txn_type) DO UPDATE SET
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
doc_number = EXCLUDED.doc_number,
|
||||
entity_ref_id = EXCLUDED.entity_ref_id,
|
||||
entity_ref_name = EXCLUDED.entity_ref_name,
|
||||
entity_type = EXCLUDED.entity_type,
|
||||
account_ref_id = EXCLUDED.account_ref_id,
|
||||
account_ref_name = EXCLUDED.account_ref_name,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
private_note = EXCLUDED.private_note,
|
||||
line_items = EXCLUDED.line_items,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
p.Id, realmId, p.TxnDate ?? null, p.DocNumber ?? null,
|
||||
p.EntityRef?.value ?? null, p.EntityRef?.name ?? null,
|
||||
p.EntityRef?.type ?? null,
|
||||
p.AccountRef?.value ?? null, p.AccountRef?.name ?? null,
|
||||
p.TotalAmt ?? null, p.CurrencyRef?.value ?? 'USD',
|
||||
p.PrivateNote ?? null,
|
||||
JSON.stringify(p.Line ?? []),
|
||||
p.SyncToken,
|
||||
p.MetaData?.CreateTime ?? null,
|
||||
p.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'purchases', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Purchase sync failed: ${msg}`);
|
||||
return { entity: 'purchases', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncJournalEntries(realmId: string, updatedSince?: Date): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const entries = await this.client.getJournalEntries(updatedSince);
|
||||
console.log(`[QboSync] Fetched ${entries.length} journal entries`);
|
||||
|
||||
let upserted = 0;
|
||||
for (const je of entries) {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_transactions
|
||||
(id, txn_type, realm_id, txn_date, doc_number, total_amt, currency_code,
|
||||
private_note, line_items, sync_token, qbo_created_at, qbo_updated_at, synced_at)
|
||||
VALUES ($1,'JournalEntry',$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW())
|
||||
ON CONFLICT (id, txn_type) DO UPDATE SET
|
||||
txn_date = EXCLUDED.txn_date,
|
||||
doc_number = EXCLUDED.doc_number,
|
||||
total_amt = EXCLUDED.total_amt,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
private_note = EXCLUDED.private_note,
|
||||
line_items = EXCLUDED.line_items,
|
||||
sync_token = EXCLUDED.sync_token,
|
||||
qbo_updated_at = EXCLUDED.qbo_updated_at,
|
||||
synced_at = NOW()`,
|
||||
[
|
||||
je.Id, realmId, je.TxnDate ?? null, je.DocNumber ?? null,
|
||||
je.TotalAmt ?? null, je.CurrencyRef?.value ?? 'USD',
|
||||
je.PrivateNote ?? null,
|
||||
JSON.stringify(je.Line ?? []),
|
||||
je.SyncToken,
|
||||
je.MetaData?.CreateTime ?? null,
|
||||
je.MetaData?.LastUpdatedTime ?? null,
|
||||
]
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
|
||||
return { entity: 'journal_entries', success: true, recordsUpserted: upserted, duration: Date.now() - start };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Journal entry sync failed: ${msg}`);
|
||||
return { entity: 'journal_entries', success: false, recordsUpserted: 0, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async syncReports(realmId: string, monthsBack: number): Promise<QboEntitySyncResult> {
|
||||
const start = Date.now();
|
||||
let upserted = 0;
|
||||
let skipped = 0;
|
||||
const errors: string[] = [];
|
||||
|
||||
try {
|
||||
// Fetch company accounting preference once
|
||||
const { reportBasis } = await this.client.getPreferences();
|
||||
console.log(`[QboSync] Report sync using accounting method: ${reportBasis}`);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < monthsBack; i++) {
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 0);
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
|
||||
const startStr = this.formatDate(periodStart);
|
||||
const endStr = this.formatDate(periodEnd);
|
||||
|
||||
// P&L
|
||||
try {
|
||||
const pl = await this.client.getProfitAndLoss(startStr, endStr, reportBasis);
|
||||
if (pl?.Header?.NoReportData === 'true') {
|
||||
console.log(`[QboSync] P&L ${startStr}: no data, skipping`);
|
||||
skipped++;
|
||||
} else {
|
||||
await this.upsertReport(realmId, 'ProfitAndLoss', periodStart, periodEnd, pl);
|
||||
console.log(`[QboSync] P&L ${startStr}: upserted`);
|
||||
upserted++;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`P&L ${startStr}: ${msg}`);
|
||||
}
|
||||
|
||||
// Balance Sheet
|
||||
try {
|
||||
const bs = await this.client.getBalanceSheet(startStr, endStr, reportBasis);
|
||||
if (bs?.Header?.NoReportData === 'true') {
|
||||
console.log(`[QboSync] BalanceSheet ${startStr}: no data, skipping`);
|
||||
skipped++;
|
||||
} else {
|
||||
await this.upsertReport(realmId, 'BalanceSheet', periodStart, periodEnd, bs);
|
||||
console.log(`[QboSync] BalanceSheet ${startStr}: upserted`);
|
||||
upserted++;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`BalanceSheet ${startStr}: ${msg}`);
|
||||
}
|
||||
|
||||
// Cash Flow
|
||||
try {
|
||||
const cf = await this.client.getCashFlow(startStr, endStr);
|
||||
if (cf?.Header?.NoReportData === 'true') {
|
||||
console.log(`[QboSync] CashFlow ${startStr}: no data, skipping`);
|
||||
skipped++;
|
||||
} else {
|
||||
await this.upsertReport(realmId, 'CashFlow', periodStart, periodEnd, cf);
|
||||
console.log(`[QboSync] CashFlow ${startStr}: upserted`);
|
||||
upserted++;
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`CashFlow ${startStr}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[QboSync] Reports: ${upserted} upserted, ${skipped} skipped, ${errors.length} errors`);
|
||||
if (errors.length > 0) {
|
||||
console.warn(`[QboSync] Report errors: ${errors.join('; ')}`);
|
||||
}
|
||||
|
||||
return {
|
||||
entity: 'reports',
|
||||
success: errors.length === 0,
|
||||
recordsUpserted: upserted,
|
||||
duration: Date.now() - start,
|
||||
error: errors.length > 0 ? errors.join('; ') : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[QboSync] Report sync failed: ${msg}`);
|
||||
return { entity: 'reports', success: false, recordsUpserted: upserted, duration: Date.now() - start, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertReport(
|
||||
realmId: string,
|
||||
reportType: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
data: QboReport
|
||||
): Promise<void> {
|
||||
await postgresClient.query(
|
||||
`INSERT INTO qbo_reports (realm_id, report_type, period_start, period_end, report_data, synced_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (realm_id, report_type, period_start, period_end) DO UPDATE SET
|
||||
report_data = EXCLUDED.report_data,
|
||||
synced_at = NOW()`,
|
||||
[realmId, reportType, periodStart, periodEnd, JSON.stringify(data)]
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
private deriveInvoiceStatus(inv: QboInvoice): string {
|
||||
if ((inv.Balance ?? 0) === 0 && (inv.TotalAmt ?? 0) > 0) return 'Paid';
|
||||
if ((inv.Balance ?? 0) > 0 && inv.DueDate && new Date(inv.DueDate) < new Date()) return 'Overdue';
|
||||
if ((inv.Balance ?? 0) > 0) return 'Open';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
private formatDate(d: Date): string {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
private async getLastSyncTime(): Promise<Date | undefined> {
|
||||
try {
|
||||
const result = await postgresClient.query<{ synced_at: Date }>(
|
||||
`SELECT MAX(synced_at) as synced_at FROM qbo_invoices`
|
||||
);
|
||||
return result.rows[0]?.synced_at ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async recordSyncHistory(
|
||||
_syncId: string,
|
||||
syncType: string,
|
||||
triggeredBy: string,
|
||||
startedAt: Date,
|
||||
status: string,
|
||||
entities: QboEntitySyncResult[],
|
||||
errorMessage?: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const totalRecords = entities.reduce((sum, e) => sum + e.recordsUpserted, 0);
|
||||
const normalizedType = syncType === 'incremental' ? 'incremental' : 'full';
|
||||
await postgresClient.query(
|
||||
`INSERT INTO sync_history
|
||||
(entity_type, sync_type, status, records_added, started_at, completed_at, triggered_by, error_message)
|
||||
VALUES ('qbo', $1, $2, $3, $4, NOW(), $5, $6)`,
|
||||
[normalizedType, status, totalRecords, startedAt, triggeredBy, errorMessage ?? null]
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn('[QboSync] Failed to record sync history:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: QboSyncService | null = null;
|
||||
|
||||
export function getQboSyncService(): QboSyncService {
|
||||
if (!_instance) {
|
||||
_instance = new QboSyncService();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
|
@ -15,13 +15,14 @@ import { isMsgraphConfigured } from './msgraph-factory';
|
|||
import { ZoomSyncService } from './zoom-sync-service';
|
||||
import { isZoomConfigured } from './zoom-factory';
|
||||
import { MorningSummaryService } from './morning-summary-service';
|
||||
import { TicketDigestService } from './ticket-digest-service';
|
||||
|
||||
export interface ScheduleConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
cron_expression: string;
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary';
|
||||
sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary' | 'ticket-digest-daily' | 'ticket-digest-weekly' | 'ticket-digest-monthly';
|
||||
years_back?: number;
|
||||
is_enabled: boolean;
|
||||
last_run?: Date;
|
||||
|
|
@ -78,6 +79,14 @@ class SyncScheduler {
|
|||
return this._morningSummaryService;
|
||||
}
|
||||
|
||||
private _ticketDigestService?: TicketDigestService;
|
||||
private getTicketDigestService(): TicketDigestService {
|
||||
if (!this._ticketDigestService) {
|
||||
this._ticketDigestService = new TicketDigestService();
|
||||
}
|
||||
return this._ticketDigestService;
|
||||
}
|
||||
|
||||
private getZoomSyncService(): ZoomSyncService {
|
||||
if (!this._zoomSyncService) {
|
||||
this._zoomSyncService = new ZoomSyncService();
|
||||
|
|
@ -242,6 +251,30 @@ class SyncScheduler {
|
|||
sync_type: 'morning-summary',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'ticket-digest-daily',
|
||||
name: 'Daily Ticket Digest',
|
||||
description: 'LLM-analyzed ticket digest for the previous day, delivered to Teams at 7 AM Mon–Fri',
|
||||
cron_expression: '0 7 * * 1-5',
|
||||
sync_type: 'ticket-digest-daily',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'ticket-digest-weekly',
|
||||
name: 'Weekly Ticket Digest',
|
||||
description: 'LLM-analyzed ticket digest for the previous week, delivered to Teams at 7 AM Monday',
|
||||
cron_expression: '0 7 * * 1',
|
||||
sync_type: 'ticket-digest-weekly',
|
||||
is_enabled: false,
|
||||
},
|
||||
{
|
||||
id: 'ticket-digest-monthly',
|
||||
name: 'Monthly Ticket Digest',
|
||||
description: 'LLM-analyzed ticket digest for the previous month, delivered to Teams at 7 AM on the 1st',
|
||||
cron_expression: '0 7 1 * *',
|
||||
sync_type: 'ticket-digest-monthly',
|
||||
is_enabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
for (const schedule of defaultSchedules) {
|
||||
|
|
@ -367,6 +400,12 @@ class SyncScheduler {
|
|||
}
|
||||
} else if (config.sync_type === 'morning-summary') {
|
||||
await this.getMorningSummaryService().run();
|
||||
} else if (config.sync_type === 'ticket-digest-daily') {
|
||||
await this.getTicketDigestService().run('daily');
|
||||
} else if (config.sync_type === 'ticket-digest-weekly') {
|
||||
await this.getTicketDigestService().run('weekly');
|
||||
} else if (config.sync_type === 'ticket-digest-monthly') {
|
||||
await this.getTicketDigestService().run('monthly');
|
||||
} else if (config.sync_type === 'incremental') {
|
||||
await this.syncService.incrementalSync('scheduled');
|
||||
} else {
|
||||
|
|
|
|||
782
lib/services/ticket-digest-service.ts
Normal file
782
lib/services/ticket-digest-service.ts
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
/**
|
||||
* Ticket Digest Report Service
|
||||
* Aggregates ticket data for daily/weekly/monthly periods, sends it to an LLM
|
||||
* for noise analysis and insights, then delivers an Adaptive Card to Teams.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
|
||||
export type DigestPeriod = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
export interface DigestConfig {
|
||||
daily_enabled: boolean;
|
||||
weekly_enabled: boolean;
|
||||
monthly_enabled: boolean;
|
||||
daily_cron: string;
|
||||
weekly_cron: string;
|
||||
monthly_cron: string;
|
||||
llm_provider: string;
|
||||
llm_model: string;
|
||||
include_noise_analysis: boolean;
|
||||
include_sla_analysis: boolean;
|
||||
include_resource_analysis: boolean;
|
||||
include_client_analysis: boolean;
|
||||
include_recommendations: boolean;
|
||||
channel_ids: number[];
|
||||
}
|
||||
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook';
|
||||
config: Record<string, any>;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface DeliveryResult {
|
||||
channelId: number;
|
||||
label: string;
|
||||
success: boolean;
|
||||
httpStatus?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TicketDigestStats {
|
||||
period: { type: DigestPeriod; start: string; end: string; label: string };
|
||||
overview: {
|
||||
total_created: number;
|
||||
total_resolved: number;
|
||||
total_open_end: number;
|
||||
avg_resolution_hours: number | null;
|
||||
avg_first_response_hours: number | null;
|
||||
total_hours_worked: number;
|
||||
};
|
||||
by_source: Array<{ source: number | null; source_label: string; count: number; pct: number }>;
|
||||
by_queue: Array<{ queue_id: number | null; queue_label: string; count: number; resolved: number; avg_resolve_hrs: number | null }>;
|
||||
by_priority: Array<{ priority: number | null; priority_label: string; count: number }>;
|
||||
by_issue_type: Array<{ issue_type: number | null; issue_label: string; count: number }>;
|
||||
top_clients: Array<{ company_id: number; company_name: string; ticket_count: number; hours_worked: number }>;
|
||||
top_resources: Array<{ resource_id: number; resource_name: string; tickets_touched: number; hours_worked: number }>;
|
||||
noise_candidates: Array<{ title: string; count: number; source: number | null; source_label: string; avg_resolve_min: number | null; sample_id: number }>;
|
||||
monitor_tickets: { total: number; auto_resolved: number; pct_of_all: number };
|
||||
sla: { first_response_met: number; first_response_missed: number; resolution_met: number; resolution_missed: number };
|
||||
comparison: {
|
||||
prev_total_created: number;
|
||||
prev_total_resolved: number;
|
||||
prev_avg_resolution_hours: number | null;
|
||||
prev_total_hours_worked: number;
|
||||
created_delta_pct: number | null;
|
||||
resolved_delta_pct: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<number, string> = {
|
||||
'-2': 'RMM Alert (Resolved)',
|
||||
'-1': 'RMM Alert',
|
||||
1: 'Phone',
|
||||
2: 'Chat/Portal',
|
||||
4: 'Email',
|
||||
6: 'Internal',
|
||||
8: 'Monitoring Alert',
|
||||
17: 'Auto-ticket',
|
||||
21: 'Voice',
|
||||
27: 'Feedback',
|
||||
30: 'Web Portal',
|
||||
35: 'Phish Alert',
|
||||
38: 'Teams',
|
||||
39: 'API',
|
||||
};
|
||||
|
||||
const PRIORITY_LABELS: Record<number, string> = {
|
||||
1: 'Critical',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low',
|
||||
6: 'Informational',
|
||||
};
|
||||
|
||||
function getPeriodBounds(period: DigestPeriod, now: Date): { start: Date; end: Date; prevStart: Date; prevEnd: Date; label: string } {
|
||||
const end = new Date(now);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
|
||||
if (period === 'daily') {
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 1);
|
||||
const prevEnd = new Date(start);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - 1);
|
||||
return { start, end, prevStart, prevEnd, label: start.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }) };
|
||||
}
|
||||
if (period === 'weekly') {
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 7);
|
||||
const prevEnd = new Date(start);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - 7);
|
||||
const label = `${start.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} – ${new Date(end.getTime() - 86400000).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}`;
|
||||
return { start, end, prevStart, prevEnd, label };
|
||||
}
|
||||
// monthly
|
||||
const start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
|
||||
const monthEnd = new Date(end.getFullYear(), end.getMonth(), 1);
|
||||
const prevStart = new Date(start.getFullYear(), start.getMonth() - 1, 1);
|
||||
const prevEnd = new Date(start);
|
||||
const label = start.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
return { start, end: monthEnd, prevStart, prevEnd, label };
|
||||
}
|
||||
|
||||
export class TicketDigestService {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Config & Webhook CRUD
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async getConfig(): Promise<DigestConfig> {
|
||||
const r = await postgresClient.query('SELECT * FROM ticket_digest_config WHERE id = 1');
|
||||
return r.rows[0] as DigestConfig;
|
||||
}
|
||||
|
||||
async updateConfig(updates: Partial<DigestConfig>): Promise<DigestConfig> {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
for (const [key, val] of Object.entries(updates)) {
|
||||
fields.push(`${key} = $${idx++}`);
|
||||
values.push(val);
|
||||
}
|
||||
if (fields.length === 0) return this.getConfig();
|
||||
fields.push('updated_at = NOW()');
|
||||
values.push(1);
|
||||
const r = await postgresClient.query(
|
||||
`UPDATE ticket_digest_config SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
return r.rows[0] as DigestConfig;
|
||||
}
|
||||
|
||||
async getAvailableChannels(): Promise<NotificationChannel[]> {
|
||||
const r = await postgresClient.query(
|
||||
'SELECT id, name, channel_type, config, is_active FROM notification_channels ORDER BY name'
|
||||
);
|
||||
return r.rows as NotificationChannel[];
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Data Aggregation
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async aggregate(period: DigestPeriod, now?: Date): Promise<TicketDigestStats> {
|
||||
const { start, end, prevStart, prevEnd, label } = getPeriodBounds(period, now ?? new Date());
|
||||
const s = start.toISOString();
|
||||
const e = end.toISOString();
|
||||
const ps = prevStart.toISOString();
|
||||
const pe = prevEnd.toISOString();
|
||||
|
||||
const [
|
||||
overviewR,
|
||||
bySourceR,
|
||||
byQueueR,
|
||||
byPriorityR,
|
||||
byIssueTypeR,
|
||||
topClientsR,
|
||||
topResourcesR,
|
||||
noiseR,
|
||||
monitorR,
|
||||
slaR,
|
||||
prevOverviewR,
|
||||
] = await Promise.all([
|
||||
// Overview
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE t.create_date >= $1 AND t.create_date < $2) as total_created,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2) as total_resolved,
|
||||
COUNT(*) FILTER (WHERE t.create_date < $2 AND (t.resolved_date_time IS NULL OR t.resolved_date_time >= $2) AND t.status NOT IN (5)) as total_open_end,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2)::numeric, 1) as avg_resolution_hours,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600) FILTER (WHERE t.first_response_date_time IS NOT NULL AND t.create_date >= $1 AND t.create_date < $2)::numeric, 1) as avg_first_response_hours,
|
||||
COALESCE(SUM(te.hours_worked), 0) as total_hours_worked
|
||||
FROM tickets t
|
||||
LEFT JOIN time_entries te ON te.ticket_id = t.id AND (te.is_deleted = false) AND te.entry_date >= $1::date AND te.entry_date < $2::date
|
||||
WHERE t.is_deleted = false AND (t.create_date >= $1 AND t.create_date < $2 OR t.resolved_date_time >= $1 AND t.resolved_date_time < $2)
|
||||
`, [s, e]),
|
||||
|
||||
// By source
|
||||
postgresClient.query(`
|
||||
SELECT t.source, COUNT(*) as count
|
||||
FROM tickets t WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.source ORDER BY count DESC
|
||||
`, [s, e]),
|
||||
|
||||
// By queue
|
||||
postgresClient.query(`
|
||||
SELECT t.queue_id, q.label as queue_label, COUNT(*) as count,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2) as resolved,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600) FILTER (WHERE t.resolved_date_time IS NOT NULL)::numeric, 1) as avg_resolve_hrs
|
||||
FROM tickets t
|
||||
LEFT JOIN queues q ON q.value = t.queue_id
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.queue_id, q.label ORDER BY count DESC LIMIT 15
|
||||
`, [s, e]),
|
||||
|
||||
// By priority
|
||||
postgresClient.query(`
|
||||
SELECT t.priority, COUNT(*) as count
|
||||
FROM tickets t WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.priority ORDER BY t.priority
|
||||
`, [s, e]),
|
||||
|
||||
// By issue type
|
||||
postgresClient.query(`
|
||||
SELECT t.issue_type, it.label as issue_label, COUNT(*) as count
|
||||
FROM tickets t
|
||||
LEFT JOIN issue_types it ON it.value = t.issue_type
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.issue_type, it.label ORDER BY count DESC LIMIT 15
|
||||
`, [s, e]),
|
||||
|
||||
// Top clients
|
||||
postgresClient.query(`
|
||||
SELECT t.company_id, c.company_name, COUNT(DISTINCT t.id) as ticket_count,
|
||||
COALESCE(SUM(te.hours_worked), 0)::float as hours_worked
|
||||
FROM tickets t
|
||||
JOIN companies c ON c.id = t.company_id
|
||||
LEFT JOIN time_entries te ON te.ticket_id = t.id AND (te.is_deleted = false) AND te.entry_date >= $1::date AND te.entry_date < $2::date
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY t.company_id, c.company_name ORDER BY ticket_count DESC LIMIT 10
|
||||
`, [s, e]),
|
||||
|
||||
// Top resources
|
||||
postgresClient.query(`
|
||||
SELECT te.resource_id, r.first_name || ' ' || r.last_name as resource_name,
|
||||
COUNT(DISTINCT te.ticket_id) as tickets_touched,
|
||||
COALESCE(SUM(te.hours_worked), 0)::float as hours_worked
|
||||
FROM time_entries te
|
||||
JOIN resources r ON r.id = te.resource_id
|
||||
WHERE te.is_deleted = false AND te.entry_date >= $1::date AND te.entry_date < $2::date AND te.ticket_id IS NOT NULL
|
||||
GROUP BY te.resource_id, r.first_name, r.last_name ORDER BY hours_worked DESC LIMIT 10
|
||||
`, [s, e]),
|
||||
|
||||
// Noise candidates — repeated titles (grouping by first 60 chars of title)
|
||||
postgresClient.query(`
|
||||
SELECT LEFT(t.title, 60) as title, COUNT(*) as count, t.source,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/60) FILTER (WHERE t.resolved_date_time IS NOT NULL)::numeric, 0) as avg_resolve_min,
|
||||
MIN(t.id) as sample_id
|
||||
FROM tickets t
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
GROUP BY LEFT(t.title, 60), t.source
|
||||
HAVING COUNT(*) >= 3
|
||||
ORDER BY count DESC LIMIT 20
|
||||
`, [s, e]),
|
||||
|
||||
// Monitor-generated tickets
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date)) < 1800) as auto_resolved
|
||||
FROM tickets t
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2 AND t.monitor_id IS NOT NULL
|
||||
`, [s, e]),
|
||||
|
||||
// SLA (using 1hr first response / 24hr resolution as baseline)
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 <= 1) as fr_met,
|
||||
COUNT(*) FILTER (WHERE t.first_response_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.first_response_date_time - t.create_date))/3600 > 1) as fr_missed,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 <= 24) as res_met,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time IS NOT NULL AND EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600 > 24) as res_missed
|
||||
FROM tickets t
|
||||
WHERE t.is_deleted = false AND t.create_date >= $1 AND t.create_date < $2
|
||||
`, [s, e]),
|
||||
|
||||
// Previous period overview for comparison
|
||||
postgresClient.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE t.create_date >= $1 AND t.create_date < $2) as total_created,
|
||||
COUNT(*) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2) as total_resolved,
|
||||
ROUND(AVG(EXTRACT(EPOCH FROM (t.resolved_date_time - t.create_date))/3600) FILTER (WHERE t.resolved_date_time >= $1 AND t.resolved_date_time < $2)::numeric, 1) as avg_resolution_hours,
|
||||
COALESCE(SUM(te.hours_worked), 0) as total_hours_worked
|
||||
FROM tickets t
|
||||
LEFT JOIN time_entries te ON te.ticket_id = t.id AND (te.is_deleted = false) AND te.entry_date >= $1::date AND te.entry_date < $2::date
|
||||
WHERE t.is_deleted = false AND (t.create_date >= $1 AND t.create_date < $2 OR t.resolved_date_time >= $1 AND t.resolved_date_time < $2)
|
||||
`, [ps, pe]),
|
||||
]);
|
||||
|
||||
const ov = overviewR.rows[0];
|
||||
const prevOv = prevOverviewR.rows[0];
|
||||
const monRow = monitorR.rows[0];
|
||||
const slaRow = slaR.rows[0];
|
||||
const totalCreated = parseInt(ov.total_created) || 0;
|
||||
const prevCreated = parseInt(prevOv.total_created) || 0;
|
||||
const prevResolved = parseInt(prevOv.total_resolved) || 0;
|
||||
|
||||
const deltaPct = (cur: number, prev: number): number | null => prev === 0 ? null : Math.round(((cur - prev) / prev) * 100);
|
||||
|
||||
return {
|
||||
period: { type: period, start: s, end: e, label },
|
||||
overview: {
|
||||
total_created: totalCreated,
|
||||
total_resolved: parseInt(ov.total_resolved) || 0,
|
||||
total_open_end: parseInt(ov.total_open_end) || 0,
|
||||
avg_resolution_hours: ov.avg_resolution_hours ? parseFloat(ov.avg_resolution_hours) : null,
|
||||
avg_first_response_hours: ov.avg_first_response_hours ? parseFloat(ov.avg_first_response_hours) : null,
|
||||
total_hours_worked: parseFloat(ov.total_hours_worked) || 0,
|
||||
},
|
||||
by_source: bySourceR.rows.map(r => ({
|
||||
source: r.source,
|
||||
source_label: SOURCE_LABELS[r.source] ?? `Source ${r.source ?? 'Unknown'}`,
|
||||
count: parseInt(r.count),
|
||||
pct: totalCreated > 0 ? Math.round((parseInt(r.count) / totalCreated) * 100) : 0,
|
||||
})),
|
||||
by_queue: byQueueR.rows.map(r => ({
|
||||
queue_id: r.queue_id,
|
||||
queue_label: r.queue_label || `Queue ${r.queue_id}`,
|
||||
count: parseInt(r.count),
|
||||
resolved: parseInt(r.resolved) || 0,
|
||||
avg_resolve_hrs: r.avg_resolve_hrs ? parseFloat(r.avg_resolve_hrs) : null,
|
||||
})),
|
||||
by_priority: byPriorityR.rows.map(r => ({
|
||||
priority: r.priority,
|
||||
priority_label: PRIORITY_LABELS[r.priority] ?? `Priority ${r.priority ?? 'Unknown'}`,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
by_issue_type: byIssueTypeR.rows.map(r => ({
|
||||
issue_type: r.issue_type,
|
||||
issue_label: r.issue_label || `Type ${r.issue_type}`,
|
||||
count: parseInt(r.count),
|
||||
})),
|
||||
top_clients: topClientsR.rows.map(r => ({
|
||||
company_id: r.company_id,
|
||||
company_name: r.company_name,
|
||||
ticket_count: parseInt(r.ticket_count),
|
||||
hours_worked: parseFloat(r.hours_worked) || 0,
|
||||
})),
|
||||
top_resources: topResourcesR.rows.map(r => ({
|
||||
resource_id: r.resource_id,
|
||||
resource_name: r.resource_name,
|
||||
tickets_touched: parseInt(r.tickets_touched),
|
||||
hours_worked: parseFloat(r.hours_worked) || 0,
|
||||
})),
|
||||
noise_candidates: noiseR.rows.map(r => ({
|
||||
title: r.title,
|
||||
count: parseInt(r.count),
|
||||
source: r.source,
|
||||
source_label: SOURCE_LABELS[r.source] ?? `Source ${r.source}`,
|
||||
avg_resolve_min: r.avg_resolve_min ? parseFloat(r.avg_resolve_min) : null,
|
||||
sample_id: parseInt(r.sample_id),
|
||||
})),
|
||||
monitor_tickets: {
|
||||
total: parseInt(monRow.total) || 0,
|
||||
auto_resolved: parseInt(monRow.auto_resolved) || 0,
|
||||
pct_of_all: totalCreated > 0 ? Math.round((parseInt(monRow.total) / totalCreated) * 100) : 0,
|
||||
},
|
||||
sla: {
|
||||
first_response_met: parseInt(slaRow.fr_met) || 0,
|
||||
first_response_missed: parseInt(slaRow.fr_missed) || 0,
|
||||
resolution_met: parseInt(slaRow.res_met) || 0,
|
||||
resolution_missed: parseInt(slaRow.res_missed) || 0,
|
||||
},
|
||||
comparison: {
|
||||
prev_total_created: prevCreated,
|
||||
prev_total_resolved: prevResolved,
|
||||
prev_avg_resolution_hours: prevOv.avg_resolution_hours ? parseFloat(prevOv.avg_resolution_hours) : null,
|
||||
prev_total_hours_worked: parseFloat(prevOv.total_hours_worked) || 0,
|
||||
created_delta_pct: deltaPct(totalCreated, prevCreated),
|
||||
resolved_delta_pct: deltaPct(parseInt(ov.total_resolved) || 0, prevResolved),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// LLM Analysis
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async analyzeWithLLM(stats: TicketDigestStats, config: DigestConfig): Promise<{ analysis: string; tokensUsed: number }> {
|
||||
const apiKey = config.llm_provider === 'anthropic'
|
||||
? process.env.ANTHROPIC_API_KEY || ''
|
||||
: process.env.OPENAI_API_KEY || '';
|
||||
|
||||
if (!apiKey) {
|
||||
// Also check workflow_settings table
|
||||
const keyRow = await postgresClient.query(
|
||||
`SELECT value FROM workflow_settings WHERE key = $1`,
|
||||
[config.llm_provider === 'anthropic' ? 'anthropic_api_key' : 'openai_api_key']
|
||||
);
|
||||
const dbKey = keyRow.rows[0]?.value?.replace(/"/g, '') || '';
|
||||
if (!dbKey) {
|
||||
return { analysis: 'LLM API key not configured. Configure it in Admin → Workflow Settings.', tokensUsed: 0 };
|
||||
}
|
||||
return this.callLLM(stats, config, dbKey);
|
||||
}
|
||||
return this.callLLM(stats, config, apiKey);
|
||||
}
|
||||
|
||||
private async callLLM(stats: TicketDigestStats, config: DigestConfig, apiKey: string): Promise<{ analysis: string; tokensUsed: number }> {
|
||||
const systemPrompt = `You are an IT service desk analyst for a managed service provider (MSP). You produce concise, actionable digest reports for management.
|
||||
|
||||
Your analysis should be structured with these sections (use markdown headers):
|
||||
${config.include_noise_analysis ? '- **Noise & Automation**: Identify repetitive/auto-generated tickets that could be suppressed or auto-resolved. Quantify the noise.' : ''}
|
||||
${config.include_sla_analysis ? '- **SLA & Response Times**: Analyze first response and resolution times. Call out any concerning trends.' : ''}
|
||||
${config.include_resource_analysis ? '- **Team Workload**: Analyze resource utilization. Flag overloaded or underutilized engineers.' : ''}
|
||||
${config.include_client_analysis ? '- **Client Spotlight**: Highlight clients with unusual ticket volume or patterns worth attention.' : ''}
|
||||
${config.include_recommendations ? '- **Recommendations**: 3-5 specific, actionable items to reduce noise, improve response times, or optimize workflows.' : ''}
|
||||
|
||||
Rules:
|
||||
- Be direct and data-driven. Reference specific numbers from the data.
|
||||
- Keep the total response under 800 words.
|
||||
- Focus on anomalies and actionable findings, not restating obvious stats.
|
||||
- If noise candidates repeat 10+ times, strongly recommend automation or suppression.
|
||||
- Compare with previous period where relevant.`;
|
||||
|
||||
const dataPayload = JSON.stringify({
|
||||
period: stats.period,
|
||||
overview: stats.overview,
|
||||
comparison: stats.comparison,
|
||||
by_source: stats.by_source.slice(0, 8),
|
||||
by_queue: stats.by_queue.slice(0, 10),
|
||||
by_priority: stats.by_priority,
|
||||
top_clients: stats.top_clients.slice(0, 8),
|
||||
top_resources: stats.top_resources.slice(0, 8),
|
||||
noise_candidates: stats.noise_candidates.slice(0, 15),
|
||||
monitor_tickets: stats.monitor_tickets,
|
||||
sla: stats.sla,
|
||||
}, null, 2);
|
||||
|
||||
const userPrompt = `Analyze this ${stats.period.type} ticket digest for ${stats.period.label}:\n\n${dataPayload}`;
|
||||
|
||||
if (config.llm_provider === 'anthropic') {
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.llm_model || 'claude-sonnet-4-20250514',
|
||||
max_tokens: 2000,
|
||||
temperature: 0.3,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userPrompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`Anthropic API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text = data.content?.find((b: any) => b.type === 'text')?.text || '';
|
||||
const tokensUsed = (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0);
|
||||
return { analysis: text, tokensUsed };
|
||||
} else {
|
||||
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.llm_model || 'gpt-4o',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2000,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
throw new Error(`OpenAI API error (${response.status}): ${err}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const text = data.choices?.[0]?.message?.content || '';
|
||||
const tokensUsed = (data.usage?.total_tokens) || 0;
|
||||
return { analysis: text, tokensUsed };
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Adaptive Card Builder
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
buildAdaptiveCard(stats: TicketDigestStats, analysis: string): object {
|
||||
const ov = stats.overview;
|
||||
const cmp = stats.comparison;
|
||||
const periodTitle = stats.period.type.charAt(0).toUpperCase() + stats.period.type.slice(1);
|
||||
const headerText = `📊 ${periodTitle} Ticket Digest — ${stats.period.label}`;
|
||||
|
||||
const delta = (cur: number, prev: number | null | undefined): string => {
|
||||
if (prev == null || prev === 0) return '';
|
||||
const pct = Math.round(((cur - prev) / prev) * 100);
|
||||
return pct > 0 ? ` ↑${pct}%` : pct < 0 ? ` ↓${Math.abs(pct)}%` : '';
|
||||
};
|
||||
|
||||
const bodyItems: object[] = [
|
||||
{ type: 'TextBlock', text: headerText, weight: 'Bolder', size: 'Large', wrap: true },
|
||||
{
|
||||
type: 'ColumnSet',
|
||||
columns: [
|
||||
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.total_created}** Created${cmp ? delta(ov.total_created, cmp.prev_total_created) : ''}`, wrap: true }] },
|
||||
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.total_resolved}** Resolved${cmp ? delta(ov.total_resolved, cmp.prev_total_resolved) : ''}`, wrap: true }] },
|
||||
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.avg_resolution_hours ?? '—'}h** Avg Resolve`, wrap: true }] },
|
||||
{ type: 'Column', width: 'stretch', items: [{ type: 'TextBlock', text: `**${ov.total_hours_worked.toFixed(1)}h** Worked`, wrap: true }] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Noise highlight
|
||||
if (stats.noise_candidates.length > 0) {
|
||||
const topNoise = stats.noise_candidates.slice(0, 5);
|
||||
const totalNoise = topNoise.reduce((s, n) => s + n.count, 0);
|
||||
const noiseFacts = topNoise.map(n => ({
|
||||
title: `${n.count}×`,
|
||||
value: `${n.title} (${n.source_label})`,
|
||||
}));
|
||||
bodyItems.push(
|
||||
{ type: 'TextBlock', text: `🔁 Top Noise — ${totalNoise} repetitive tickets`, weight: 'Bolder', spacing: 'Medium', wrap: true },
|
||||
{ type: 'FactSet', facts: noiseFacts },
|
||||
);
|
||||
}
|
||||
|
||||
// Monitor tickets
|
||||
if (stats.monitor_tickets.total > 0) {
|
||||
bodyItems.push({
|
||||
type: 'TextBlock',
|
||||
text: `🤖 Monitor-generated: **${stats.monitor_tickets.total}** (${stats.monitor_tickets.pct_of_all}% of all) · ${stats.monitor_tickets.auto_resolved} auto-resolved (<30m)`,
|
||||
spacing: 'Medium', wrap: true,
|
||||
});
|
||||
}
|
||||
|
||||
// SLA summary
|
||||
const totalFR = stats.sla.first_response_met + stats.sla.first_response_missed;
|
||||
const totalRes = stats.sla.resolution_met + stats.sla.resolution_missed;
|
||||
if (totalFR > 0 || totalRes > 0) {
|
||||
const frPct = totalFR > 0 ? Math.round((stats.sla.first_response_met / totalFR) * 100) : 0;
|
||||
const resPct = totalRes > 0 ? Math.round((stats.sla.resolution_met / totalRes) * 100) : 0;
|
||||
bodyItems.push({
|
||||
type: 'TextBlock',
|
||||
text: `⏱️ SLA: First Response **${frPct}%** met (≤1h) · Resolution **${resPct}%** met (≤24h)`,
|
||||
spacing: 'Small', wrap: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Top clients
|
||||
if (stats.top_clients.length > 0) {
|
||||
const clientFacts = stats.top_clients.slice(0, 5).map(c => ({
|
||||
title: `${c.ticket_count} tickets`,
|
||||
value: `${c.company_name} (${c.hours_worked.toFixed(1)}h)`,
|
||||
}));
|
||||
bodyItems.push(
|
||||
{ type: 'TextBlock', text: '🏢 Top Clients', weight: 'Bolder', spacing: 'Medium', wrap: true },
|
||||
{ type: 'FactSet', facts: clientFacts },
|
||||
);
|
||||
}
|
||||
|
||||
// LLM analysis section (split into paragraphs for readability)
|
||||
if (analysis && analysis.length > 20) {
|
||||
bodyItems.push(
|
||||
{ type: 'TextBlock', text: '🧠 AI Analysis', weight: 'Bolder', size: 'Medium', spacing: 'Large', wrap: true },
|
||||
);
|
||||
// Truncate for Adaptive Card limits (~28KB) and split on headers
|
||||
const truncated = analysis.substring(0, 3500);
|
||||
const sections = truncated.split(/(?=^##?\s)/m).filter(s => s.trim());
|
||||
for (const section of sections.slice(0, 6)) {
|
||||
bodyItems.push({ type: 'TextBlock', text: section.trim(), wrap: true, spacing: 'Small' });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.4',
|
||||
body: bodyItems,
|
||||
actions: [
|
||||
{ type: 'Action.OpenUrl', title: 'Open Pulse', url: 'https://pulse.wulfconsulting.cloud' },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Delivery
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async deliver(card: object, stats: TicketDigestStats, channelIds?: number[]): Promise<DeliveryResult[]> {
|
||||
const config = await this.getConfig();
|
||||
const ids = channelIds ?? config.channel_ids ?? [];
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const channelRows = await postgresClient.query(
|
||||
'SELECT id, name, channel_type, config, is_active FROM notification_channels WHERE id = ANY($1)',
|
||||
[ids]
|
||||
);
|
||||
const channels = channelRows.rows as NotificationChannel[];
|
||||
|
||||
const teamsEnvelope = {
|
||||
type: 'message',
|
||||
attachments: [{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
contentUrl: null,
|
||||
content: card,
|
||||
}],
|
||||
};
|
||||
|
||||
const plainText = this.buildPlainTextSummary(stats);
|
||||
|
||||
const results: DeliveryResult[] = await Promise.all(
|
||||
channels.map(async (ch): Promise<DeliveryResult> => {
|
||||
try {
|
||||
let res: Response;
|
||||
if (ch.channel_type === 'teams') {
|
||||
const url = ch.config.webhook_url;
|
||||
if (!url) throw new Error('Teams channel missing webhook_url');
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(teamsEnvelope),
|
||||
});
|
||||
} else if (ch.channel_type === 'telegram') {
|
||||
const { bot_token, chat_id, parse_mode } = ch.config;
|
||||
if (!bot_token || !chat_id) throw new Error('Telegram missing bot_token or chat_id');
|
||||
res = await fetch(`https://api.telegram.org/bot${bot_token}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chat_id, text: plainText, parse_mode: parse_mode || 'HTML' }),
|
||||
});
|
||||
} else if (ch.channel_type === 'ntfy') {
|
||||
const server = ch.config.server_url || 'https://ntfy.sh';
|
||||
const topic = ch.config.topic;
|
||||
if (!topic) throw new Error('ntfy missing topic');
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/plain', 'Title': `Ticket Digest — ${stats.period.label}` };
|
||||
if (ch.config.auth_token) headers['Authorization'] = `Bearer ${ch.config.auth_token}`;
|
||||
if (ch.config.default_priority) headers['Priority'] = ch.config.default_priority;
|
||||
res = await fetch(`${server}/${topic}`, { method: 'POST', headers, body: plainText });
|
||||
} else {
|
||||
const url = ch.config.url;
|
||||
if (!url) throw new Error('Webhook channel missing url');
|
||||
res = await fetch(url, {
|
||||
method: ch.config.method || 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(ch.config.headers || {}) },
|
||||
body: JSON.stringify({ title: `Ticket Digest — ${stats.period.label}`, text: plainText, stats: stats.overview }),
|
||||
});
|
||||
}
|
||||
return { channelId: ch.id, label: ch.name, success: res.ok, httpStatus: res.status };
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
return { channelId: ch.id, label: ch.name, success: false, error };
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private buildPlainTextSummary(stats: TicketDigestStats): string {
|
||||
const ov = stats.overview;
|
||||
const lines = [
|
||||
`📊 Ticket Digest — ${stats.period.label}`,
|
||||
`Created: ${ov.total_created} | Resolved: ${ov.total_resolved} | Open: ${ov.total_open_end}`,
|
||||
`Avg Resolution: ${ov.avg_resolution_hours ?? '—'}h | Hours Worked: ${ov.total_hours_worked.toFixed(1)}h`,
|
||||
];
|
||||
if (stats.monitor_tickets.total > 0) {
|
||||
lines.push(`Monitor alerts: ${stats.monitor_tickets.total} (${stats.monitor_tickets.pct_of_all}% of all, ${stats.monitor_tickets.auto_resolved} auto-resolved)`);
|
||||
}
|
||||
if (stats.noise_candidates.length > 0) {
|
||||
lines.push(`Top noise: ${stats.noise_candidates.slice(0, 3).map(n => `${n.title} (${n.count}×)`).join(', ')}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Full Run
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async run(period: DigestPeriod, channelIds?: number[]): Promise<{
|
||||
stats: TicketDigestStats;
|
||||
analysis: string;
|
||||
deliveryResults: DeliveryResult[];
|
||||
processingTimeMs: number;
|
||||
}> {
|
||||
const startTime = Date.now();
|
||||
const config = await this.getConfig();
|
||||
|
||||
console.log(`[TICKET-DIGEST] Generating ${period} report...`);
|
||||
|
||||
// 1. Aggregate data
|
||||
const stats = await this.aggregate(period);
|
||||
console.log(`[TICKET-DIGEST] Aggregated: ${stats.overview.total_created} created, ${stats.overview.total_resolved} resolved`);
|
||||
|
||||
// 2. LLM analysis
|
||||
let analysis = '';
|
||||
let tokensUsed = 0;
|
||||
try {
|
||||
const llmResult = await this.analyzeWithLLM(stats, config);
|
||||
analysis = llmResult.analysis;
|
||||
tokensUsed = llmResult.tokensUsed;
|
||||
console.log(`[TICKET-DIGEST] LLM analysis complete (${tokensUsed} tokens)`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[TICKET-DIGEST] LLM analysis failed: ${msg}`);
|
||||
analysis = `LLM analysis unavailable: ${msg}`;
|
||||
}
|
||||
|
||||
// 3. Build card
|
||||
const card = this.buildAdaptiveCard(stats, analysis);
|
||||
|
||||
// 4. Persist
|
||||
const processingTimeMs = Date.now() - startTime;
|
||||
await postgresClient.query(
|
||||
`INSERT INTO ticket_digest_reports (period_type, period_start, period_end, stats, llm_analysis, card_payload, tokens_used, processing_time_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[period, stats.period.start, stats.period.end, JSON.stringify(stats), analysis, JSON.stringify(card), tokensUsed, processingTimeMs]
|
||||
);
|
||||
|
||||
// 5. Deliver
|
||||
const deliveryResults = await this.deliver(card, stats, channelIds);
|
||||
console.log(`[TICKET-DIGEST] Delivered to ${deliveryResults.filter(r => r.success).length}/${deliveryResults.length} channels`);
|
||||
|
||||
// Update delivery status
|
||||
const statusMap: Record<number, object> = {};
|
||||
for (const r of deliveryResults) {
|
||||
statusMap[r.channelId] = { success: r.success, httpStatus: r.httpStatus, error: r.error };
|
||||
}
|
||||
await postgresClient.query(
|
||||
`UPDATE ticket_digest_reports SET delivery_status = $1
|
||||
WHERE id = (SELECT id FROM ticket_digest_reports ORDER BY generated_at DESC LIMIT 1)`,
|
||||
[JSON.stringify(statusMap)]
|
||||
);
|
||||
|
||||
return { stats, analysis, deliveryResults, processingTimeMs };
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// History
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
async getHistory(limit = 20): Promise<Array<{
|
||||
id: number;
|
||||
period_type: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
generated_at: string;
|
||||
stats: TicketDigestStats;
|
||||
llm_analysis: string | null;
|
||||
delivery_status: object;
|
||||
tokens_used: number | null;
|
||||
processing_time_ms: number | null;
|
||||
}>> {
|
||||
const r = await postgresClient.query(
|
||||
'SELECT * FROM ticket_digest_reports ORDER BY generated_at DESC LIMIT $1',
|
||||
[limit]
|
||||
);
|
||||
return r.rows;
|
||||
}
|
||||
}
|
||||
|
||||
let _instance: TicketDigestService | null = null;
|
||||
export function getTicketDigestService(): TicketDigestService {
|
||||
if (!_instance) _instance = new TicketDigestService();
|
||||
return _instance;
|
||||
}
|
||||
|
|
@ -132,6 +132,39 @@ export class ZabbixClient {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PROBLEM trigger events (value=1) for a set of hostids within a time range.
|
||||
* Used to build the local zabbix_events correlation cache.
|
||||
*/
|
||||
async getEvents(params: {
|
||||
hostIds: string[];
|
||||
from: Date;
|
||||
to: Date;
|
||||
limit?: number;
|
||||
}): Promise<Array<{
|
||||
eventid: string;
|
||||
objectid: string;
|
||||
name: string;
|
||||
severity: string;
|
||||
clock: string;
|
||||
r_eventid: string;
|
||||
r_clock: string;
|
||||
hosts: Array<{ hostid: string }>;
|
||||
}>> {
|
||||
// problem.get supports r_clock output; event.get does not
|
||||
return this.rpc('problem.get', {
|
||||
output: ['eventid', 'objectid', 'name', 'severity', 'clock', 'r_eventid', 'r_clock'],
|
||||
time_from: Math.floor(params.from.getTime() / 1000),
|
||||
time_till: Math.floor(params.to.getTime() / 1000),
|
||||
hostids: params.hostIds,
|
||||
selectHosts: ['hostid'],
|
||||
recent: false,
|
||||
sortfield: 'eventid',
|
||||
sortorder: 'DESC',
|
||||
limit: params.limit ?? 10000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete one or more hosts by their hostids.
|
||||
*/
|
||||
|
|
@ -261,4 +294,42 @@ export class ZabbixClient {
|
|||
await this.rpc<{ hostids: string[] }>('host.update', updateParams);
|
||||
return { action: 'updated', hostid: existing.hostid };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find hosts by DNS name or IP address across all interfaces.
|
||||
* Used to correlate Datto RMM ping targets with Zabbix hosts.
|
||||
*/
|
||||
async findHostsByInterface(dnsOrIp: string): Promise<Array<{ hostid: string; name: string }>> {
|
||||
return this.rpc<Array<{ hostid: string; name: string }>>('host.get', {
|
||||
output: ['hostid', 'name'],
|
||||
filter: { ip: [dnsOrIp], dns: [dnsOrIp] },
|
||||
searchByAny: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get open problems for a host.
|
||||
*/
|
||||
async getOpenProblemsForHost(hostid: string): Promise<ZabbixProblem[]> {
|
||||
return this.rpc<ZabbixProblem[]>('problem.get', {
|
||||
output: 'extend',
|
||||
hostids: [hostid],
|
||||
recent: true,
|
||||
selectAcknowledges: 'count',
|
||||
selectSuppressionData: 'extend',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress a Zabbix problem event until a given timestamp.
|
||||
* action=16 = suppress, action=4 = acknowledge with message.
|
||||
* We combine both (action=20) to add a note and suppress.
|
||||
*/
|
||||
async suppressProblem(eventid: string, _suppressUntil: Date, message: string): Promise<void> {
|
||||
await this.rpc<{ eventids: string[] }>('event.acknowledge', {
|
||||
eventids: [eventid],
|
||||
action: 20, // 4 (add message) + 16 (suppress)
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
161
lib/types/qbo.ts
Normal file
161
lib/types/qbo.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
export interface QboTokenRecord {
|
||||
id: number;
|
||||
realm_id: string;
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
access_token_expires_at: Date;
|
||||
refresh_token_expires_at: Date;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface QboTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
x_refresh_token_expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface QboRef {
|
||||
value: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface QboMetaData {
|
||||
CreateTime: string;
|
||||
LastUpdatedTime: string;
|
||||
}
|
||||
|
||||
export interface QboLineItem {
|
||||
Id?: string;
|
||||
LineNum?: number;
|
||||
Description?: string;
|
||||
Amount: number;
|
||||
DetailType: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface QboLinkedTxn {
|
||||
TxnId: string;
|
||||
TxnType: string;
|
||||
TxnLineId?: string;
|
||||
}
|
||||
|
||||
// Invoice
|
||||
export interface QboInvoice {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
DocNumber?: string;
|
||||
TxnDate?: string;
|
||||
DueDate?: string;
|
||||
CustomerRef?: QboRef;
|
||||
BillEmail?: { Address?: string };
|
||||
TotalAmt?: number;
|
||||
Balance?: number;
|
||||
EmailStatus?: string;
|
||||
PrintStatus?: string;
|
||||
CurrencyRef?: QboRef;
|
||||
Line?: QboLineItem[];
|
||||
LinkedTxn?: QboLinkedTxn[];
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Payment
|
||||
export interface QboPayment {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
TxnDate?: string;
|
||||
CustomerRef?: QboRef;
|
||||
TotalAmt?: number;
|
||||
UnappliedAmt?: number;
|
||||
CurrencyRef?: QboRef;
|
||||
PaymentMethodRef?: QboRef;
|
||||
DepositToAccountRef?: QboRef;
|
||||
Line?: Array<{ Amount: number; LinkedTxn?: QboLinkedTxn[] }>;
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Deposit
|
||||
export interface QboDeposit {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
TxnDate?: string;
|
||||
DepositToAccountRef?: QboRef;
|
||||
TotalAmt?: number;
|
||||
Line?: QboLineItem[];
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Purchase (expense/credit card)
|
||||
export interface QboPurchase {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
TxnDate?: string;
|
||||
DocNumber?: string;
|
||||
EntityRef?: QboRef & { type?: string };
|
||||
AccountRef?: QboRef;
|
||||
TotalAmt?: number;
|
||||
CurrencyRef?: QboRef;
|
||||
PrivateNote?: string;
|
||||
Line?: QboLineItem[];
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Journal Entry
|
||||
export interface QboJournalEntry {
|
||||
Id: string;
|
||||
SyncToken: string;
|
||||
TxnDate?: string;
|
||||
DocNumber?: string;
|
||||
TotalAmt?: number;
|
||||
CurrencyRef?: QboRef;
|
||||
PrivateNote?: string;
|
||||
Line?: QboLineItem[];
|
||||
MetaData?: QboMetaData;
|
||||
}
|
||||
|
||||
// Report (P&L, Balance Sheet)
|
||||
export interface QboReport {
|
||||
Header?: {
|
||||
ReportName?: string;
|
||||
StartPeriod?: string;
|
||||
EndPeriod?: string;
|
||||
Time?: string;
|
||||
Currency?: string;
|
||||
ReportBasis?: string;
|
||||
NoReportData?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
Columns?: any;
|
||||
Rows?: any;
|
||||
}
|
||||
|
||||
export interface QboQueryResponse<T> {
|
||||
QueryResponse: {
|
||||
[key: string]: T[] | number | undefined;
|
||||
startPosition?: number;
|
||||
maxResults?: number;
|
||||
totalCount?: number;
|
||||
};
|
||||
time: string;
|
||||
}
|
||||
|
||||
export interface QboSyncResult {
|
||||
syncId: string;
|
||||
realmId: string;
|
||||
status: 'completed' | 'failed';
|
||||
startedAt: Date;
|
||||
completedAt: Date;
|
||||
duration: number;
|
||||
entities: QboEntitySyncResult[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface QboEntitySyncResult {
|
||||
entity: string;
|
||||
success: boolean;
|
||||
recordsUpserted: number;
|
||||
duration: number;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -28,7 +28,8 @@ export enum WebhookEntityType {
|
|||
}
|
||||
|
||||
/**
|
||||
* Autotask entities that support webhooks via their REST API
|
||||
* Entities Autotask actually exposes a webhook REST endpoint for.
|
||||
* TimeEntries, Tasks, Projects, Contracts return 404 — not supported by Autotask.
|
||||
*/
|
||||
export const WEBHOOK_SUPPORTED_ENTITIES: WebhookEntityType[] = [
|
||||
WebhookEntityType.COMPANIES,
|
||||
|
|
@ -115,6 +116,10 @@ const ENTITY_TYPE_MAP: Record<string, WebhookEntityType> = {
|
|||
'InstalledProduct': WebhookEntityType.CONFIGURATION_ITEMS, // Autotask actual payload name
|
||||
'Ticket': WebhookEntityType.TICKETS,
|
||||
'TicketNote': WebhookEntityType.TICKET_NOTES,
|
||||
'TimeEntry': WebhookEntityType.TIME_ENTRIES,
|
||||
'Task': WebhookEntityType.TASKS,
|
||||
'Project': WebhookEntityType.PROJECTS,
|
||||
'Contract': WebhookEntityType.CONTRACTS,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue