- 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
453 lines
17 KiB
TypeScript
453 lines
17 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { postgresClient } from '@/lib/services/postgres-client';
|
|
|
|
async function getExcludedCompanyIds(): Promise<number[]> {
|
|
try {
|
|
const result = await postgresClient.query(
|
|
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_company_ids'`
|
|
);
|
|
const value = result.rows[0]?.setting_value || '';
|
|
return value ? value.split(',').map((id: string) => parseInt(id.trim())).filter((id: number) => !isNaN(id)) : [];
|
|
} catch (error) {
|
|
console.error('Error fetching excluded company IDs:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async function getExcludedClassifications(): Promise<string[]> {
|
|
try {
|
|
const result = await postgresClient.query(
|
|
`SELECT setting_value FROM kiosk_settings WHERE setting_key = 'excluded_classifications'`
|
|
);
|
|
const value = result.rows[0]?.setting_value || '';
|
|
return value ? value.split(',').map((c: string) => c.trim()).filter(Boolean) : [];
|
|
} catch (error) {
|
|
console.error('Error fetching excluded classifications:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
// Get excluded company IDs (co-managed clients)
|
|
const excludedCompanyIds = await getExcludedCompanyIds();
|
|
const excludedClassifications = await getExcludedClassifications();
|
|
|
|
// Build company exclusion filter
|
|
let excludeCompanyFilter = '';
|
|
if (excludedCompanyIds.length > 0 || excludedClassifications.length > 0) {
|
|
const conditions = [];
|
|
if (excludedCompanyIds.length > 0) {
|
|
conditions.push(`company_id NOT IN (${excludedCompanyIds.join(',')})`);
|
|
}
|
|
if (excludedClassifications.length > 0) {
|
|
const classificationList = excludedClassifications.map(c => `'${c.replace(/'/g, "''")}'`).join(',');
|
|
conditions.push(`company_id NOT IN (SELECT id FROM companies WHERE classification IN (${classificationList}))`);
|
|
}
|
|
excludeCompanyFilter = `AND (${conditions.join(' AND ')})`;
|
|
}
|
|
|
|
// Critical tickets (Priority 1-3) - exclude RMM alerts (source = 8), co-managed clients, and excluded classifications
|
|
const criticalTicketsResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets
|
|
WHERE completed_date IS NULL
|
|
AND is_deleted = false
|
|
AND priority <= 3
|
|
AND (source IS NULL OR source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Top 10 critical tickets
|
|
const topCriticalResult = await postgresClient.query(
|
|
`SELECT t.ticket_number, t.title, c.company_name, t.priority
|
|
FROM tickets t
|
|
LEFT JOIN companies c ON t.company_id = c.id
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.priority <= 3
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
${excludeCompanyFilter}
|
|
ORDER BY t.priority ASC, t.create_date ASC
|
|
LIMIT 10`
|
|
);
|
|
|
|
// Tickets waiting engagement (specific statuses) - exclude RMM alerts
|
|
const waitingTicketsResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets
|
|
WHERE completed_date IS NULL
|
|
AND is_deleted = false
|
|
AND status IN (21, 9, 19)
|
|
AND (source IS NULL OR source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Top 10 waiting tickets
|
|
const topWaitingResult = await postgresClient.query(
|
|
`SELECT t.ticket_number, t.title, c.company_name, t.last_activity_date
|
|
FROM tickets t
|
|
LEFT JOIN companies c ON t.company_id = c.id
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.status IN (21, 9, 19)
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
${excludeCompanyFilter}
|
|
ORDER BY t.last_activity_date ASC NULLS FIRST
|
|
LIMIT 10`
|
|
);
|
|
|
|
// Stale tickets (no activity in 7+ days) - exclude RMM alerts
|
|
const staleTicketsResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets
|
|
WHERE completed_date IS NULL
|
|
AND is_deleted = false
|
|
AND last_activity_date < NOW() - INTERVAL '7 days'
|
|
AND (source IS NULL OR source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Top 10 stale tickets (oldest activity first)
|
|
const topStaleResult = await postgresClient.query(
|
|
`SELECT t.ticket_number, t.title, c.company_name, t.last_activity_date
|
|
FROM tickets t
|
|
LEFT JOIN companies c ON t.company_id = c.id
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.last_activity_date < NOW() - INTERVAL '7 days'
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
${excludeCompanyFilter}
|
|
ORDER BY t.last_activity_date ASC NULLS FIRST
|
|
LIMIT 10`
|
|
);
|
|
|
|
// Overdue tickets - exclude RMM alerts
|
|
const overdueTicketsResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets
|
|
WHERE completed_date IS NULL
|
|
AND is_deleted = false
|
|
AND due_date_time < NOW()
|
|
AND (source IS NULL OR source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Top 10 overdue tickets (most overdue first)
|
|
const topOverdueResult = await postgresClient.query(
|
|
`SELECT t.ticket_number, t.title, c.company_name, t.due_date_time
|
|
FROM tickets t
|
|
LEFT JOIN companies c ON t.company_id = c.id
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.due_date_time < NOW()
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
${excludeCompanyFilter}
|
|
ORDER BY t.due_date_time ASC
|
|
LIMIT 10`
|
|
);
|
|
|
|
// Total open tickets - exclude RMM alerts
|
|
const openTicketsResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets
|
|
WHERE completed_date IS NULL
|
|
AND is_deleted = false
|
|
AND (source IS NULL OR source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Tickets closed today - exclude RMM alerts
|
|
const closedTodayResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets
|
|
WHERE DATE(completed_date) = CURRENT_DATE
|
|
AND is_deleted = false
|
|
AND (source IS NULL OR source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Top 3 recently closed tickets
|
|
const topClosedResult = await postgresClient.query(
|
|
`SELECT t.ticket_number, t.title, c.company_name, t.completed_date
|
|
FROM tickets t
|
|
LEFT JOIN companies c ON t.company_id = c.id
|
|
WHERE DATE(t.completed_date) = CURRENT_DATE
|
|
AND t.is_deleted = false
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
${excludeCompanyFilter}
|
|
ORDER BY t.completed_date DESC
|
|
LIMIT 3`
|
|
);
|
|
|
|
// Average closed tickets per day last week
|
|
const avgClosedLastWeekResult = await postgresClient.query(
|
|
`SELECT ROUND(COUNT(*)::numeric / 7, 1) as avg_per_day
|
|
FROM tickets
|
|
WHERE completed_date >= NOW() - INTERVAL '7 days'
|
|
AND completed_date < NOW()
|
|
AND is_deleted = false
|
|
AND (source IS NULL OR source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Average closed tickets per day last month
|
|
const avgClosedLastMonthResult = await postgresClient.query(
|
|
`SELECT ROUND(COUNT(*)::numeric / 30, 1) as avg_per_day
|
|
FROM tickets
|
|
WHERE completed_date >= NOW() - INTERVAL '30 days'
|
|
AND completed_date < NOW()
|
|
AND is_deleted = false
|
|
AND (source IS NULL OR source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Average resolution time (last 30 days) - exclude RMM alerts
|
|
const avgResolutionResult = await postgresClient.query(
|
|
`SELECT AVG(EXTRACT(EPOCH FROM (completed_date - create_date))/3600) as avg_hours
|
|
FROM tickets
|
|
WHERE completed_date >= NOW() - INTERVAL '30 days'
|
|
AND completed_date IS NOT NULL
|
|
AND is_deleted = false
|
|
AND (source IS NULL OR source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Time entries this week
|
|
const timeEntriesResult = await postgresClient.query(
|
|
`SELECT COALESCE(SUM(hours_worked), 0) as hours
|
|
FROM time_entries
|
|
WHERE entry_date >= DATE_TRUNC('week', CURRENT_DATE)`
|
|
);
|
|
|
|
// Top ticket closers (last 30 days) - using last_activity_resource_id, excluding Autotask system user
|
|
// Only count tickets where the resource has at least 10 minutes (0.167 hours) of time worked
|
|
const topTicketClosersResult = await postgresClient.query(
|
|
`SELECT r.first_name || ' ' || r.last_name as resource_name,
|
|
COUNT(DISTINCT t.id) as tickets_closed
|
|
FROM tickets t
|
|
INNER JOIN resources r ON t.last_activity_resource_id = r.id
|
|
INNER JOIN (
|
|
SELECT ticket_id, resource_id, SUM(hours_worked) as total_hours
|
|
FROM time_entries
|
|
GROUP BY ticket_id, resource_id
|
|
HAVING SUM(hours_worked) >= 0.167
|
|
) te ON t.id = te.ticket_id AND r.id = te.resource_id
|
|
WHERE t.completed_date >= NOW() - INTERVAL '30 days'
|
|
AND t.completed_date IS NOT NULL
|
|
AND t.is_deleted = false
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
AND r.id != 4
|
|
${excludeCompanyFilter}
|
|
GROUP BY r.id, r.first_name, r.last_name
|
|
ORDER BY tickets_closed DESC
|
|
LIMIT 10`
|
|
);
|
|
|
|
// Active companies
|
|
const companiesResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM companies
|
|
WHERE is_active = true AND company_type = 1`
|
|
);
|
|
|
|
// Quotes (if table exists)
|
|
let quotesOpen = 0;
|
|
try {
|
|
const quotesResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM quotes
|
|
WHERE status = 'open'`
|
|
);
|
|
quotesOpen = parseInt(quotesResult.rows[0]?.count || '0');
|
|
} catch {
|
|
// Quotes table may not exist
|
|
}
|
|
|
|
// NMS Coverage
|
|
let nmsPercentage = 0;
|
|
try {
|
|
const nmsResult = await postgresClient.query(
|
|
`SELECT
|
|
(SELECT COUNT(*) FROM auvik_tenant_mappings WHERE autotask_company_id IS NOT NULL) as mapped,
|
|
(SELECT COUNT(*) FROM auvik_tenants) as total`
|
|
);
|
|
const mapped = parseInt(nmsResult.rows[0]?.mapped || '0');
|
|
const total = parseInt(nmsResult.rows[0]?.total || '0');
|
|
nmsPercentage = total > 0 ? Math.round((mapped / total) * 100) : 0;
|
|
} catch {
|
|
// Tables may not exist
|
|
}
|
|
|
|
// RMM Coverage
|
|
let rmmPercentage = 0;
|
|
try {
|
|
const rmmResult = await postgresClient.query(
|
|
`SELECT
|
|
(SELECT COUNT(*) FROM rmm_site_mappings WHERE company_id IS NOT NULL) as mapped,
|
|
(SELECT COUNT(*) FROM rmm_sites) as total`
|
|
);
|
|
const mapped = parseInt(rmmResult.rows[0]?.mapped || '0');
|
|
const total = parseInt(rmmResult.rows[0]?.total || '0');
|
|
rmmPercentage = total > 0 ? Math.round((mapped / total) * 100) : 0;
|
|
} catch {
|
|
// Tables may not exist
|
|
}
|
|
|
|
// Service Desk - Managed (using primary service desk queues, excluding vendor and alerts)
|
|
const serviceDeskManagedResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets t
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.status NOT IN (21, 9, 19)
|
|
AND t.ticket_type != 4
|
|
AND t.queue_id IN (29682833, 29749490)
|
|
AND (t.ticket_category IS NULL OR t.ticket_category != 171)
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Top Issue/Sub-Issue types for Managed Service tickets
|
|
const topManagedIssueTypesResult = await postgresClient.query(
|
|
`SELECT
|
|
COALESCE(it.label, 'Not Set') as issue_type,
|
|
COALESCE(sit.label, 'Not Set') as sub_issue_type,
|
|
COUNT(*) as count
|
|
FROM tickets t
|
|
LEFT JOIN issue_types it ON t.issue_type = it.value
|
|
LEFT JOIN sub_issue_types sit ON t.sub_issue_type = sit.value
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.status NOT IN (21, 9, 19)
|
|
AND t.ticket_type != 4
|
|
AND t.queue_id IN (29682833, 29749490)
|
|
AND (t.ticket_category IS NULL OR t.ticket_category != 171)
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
${excludeCompanyFilter}
|
|
GROUP BY it.label, sit.label
|
|
ORDER BY count DESC
|
|
LIMIT 10`
|
|
);
|
|
|
|
// Service Desk - T&M (Level 1/2 Support queues)
|
|
const serviceDeskTMResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets t
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.status NOT IN (21, 9, 19)
|
|
AND t.ticket_type != 4
|
|
AND t.queue_id IN (29853766, 29853700)
|
|
AND (t.ticket_category IS NULL OR t.ticket_category != 171)
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Vendor Service Desk (All)
|
|
const serviceDeskVendorResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets t
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.ticket_category = 171
|
|
AND (t.source IS NULL OR t.source != 8)
|
|
${excludeCompanyFilter}`
|
|
);
|
|
|
|
// Key Accounts - Hynes Industries
|
|
const hynesTicketsResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets t
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.company_id = 29861375
|
|
AND (t.source IS NULL OR t.source != 8)`
|
|
);
|
|
|
|
// Key Accounts - Seubert and Associates
|
|
const seubertTicketsResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets t
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.company_id = 29683407
|
|
AND (t.source IS NULL OR t.source != 8)`
|
|
);
|
|
|
|
// Key Accounts - Universal Plastics (both locations)
|
|
const universalTicketsResult = await postgresClient.query(
|
|
`SELECT COUNT(*) as count
|
|
FROM tickets t
|
|
WHERE t.completed_date IS NULL
|
|
AND t.is_deleted = false
|
|
AND t.company_id IN (29861395, 29861424)
|
|
AND (t.source IS NULL OR t.source != 8)`
|
|
);
|
|
|
|
const stats = {
|
|
criticalTickets: parseInt(criticalTicketsResult.rows[0]?.count || '0'),
|
|
topCritical: topCriticalResult.rows.map((r: any) => ({
|
|
ticketNumber: r.ticket_number,
|
|
title: r.title,
|
|
companyName: r.company_name,
|
|
})),
|
|
waitingTickets: parseInt(waitingTicketsResult.rows[0]?.count || '0'),
|
|
topWaiting: topWaitingResult.rows.map((r: any) => ({
|
|
ticketNumber: r.ticket_number,
|
|
title: r.title,
|
|
companyName: r.company_name,
|
|
})),
|
|
staleTickets: parseInt(staleTicketsResult.rows[0]?.count || '0'),
|
|
topStale: topStaleResult.rows.map((r: any) => ({
|
|
ticketNumber: r.ticket_number,
|
|
title: r.title,
|
|
companyName: r.company_name,
|
|
})),
|
|
overdueTickets: parseInt(overdueTicketsResult.rows[0]?.count || '0'),
|
|
topOverdue: topOverdueResult.rows.map((r: any) => ({
|
|
ticketNumber: r.ticket_number,
|
|
title: r.title,
|
|
companyName: r.company_name,
|
|
})),
|
|
openTickets: parseInt(openTicketsResult.rows[0]?.count || '0'),
|
|
closedToday: parseInt(closedTodayResult.rows[0]?.count || '0'),
|
|
avgClosedLastWeek: parseFloat(avgClosedLastWeekResult.rows[0]?.avg_per_day || '0'),
|
|
avgClosedLastMonth: parseFloat(avgClosedLastMonthResult.rows[0]?.avg_per_day || '0'),
|
|
topClosed: topClosedResult.rows.map((r: any) => ({
|
|
ticketNumber: r.ticket_number,
|
|
title: r.title,
|
|
companyName: r.company_name,
|
|
})),
|
|
avgResolutionHours: parseFloat(avgResolutionResult.rows[0]?.avg_hours || '0'),
|
|
hoursThisWeek: parseFloat(timeEntriesResult.rows[0]?.hours || '0'),
|
|
activeCompanies: parseInt(companiesResult.rows[0]?.count || '0'),
|
|
openQuotes: quotesOpen,
|
|
topTicketClosers: topTicketClosersResult.rows.map((r: any) => ({
|
|
resourceName: r.resource_name,
|
|
ticketsClosed: parseInt(r.tickets_closed),
|
|
})),
|
|
nmsCoverage: nmsPercentage,
|
|
rmmCoverage: rmmPercentage,
|
|
serviceDeskManaged: parseInt(serviceDeskManagedResult.rows[0]?.count || '0'),
|
|
topManagedIssueTypes: topManagedIssueTypesResult.rows.map((r: any) => ({
|
|
issueType: r.issue_type,
|
|
subIssueType: r.sub_issue_type,
|
|
count: parseInt(r.count),
|
|
})),
|
|
serviceDeskTM: parseInt(serviceDeskTMResult.rows[0]?.count || '0'),
|
|
serviceDeskVendor: parseInt(serviceDeskVendorResult.rows[0]?.count || '0'),
|
|
hynesTickets: parseInt(hynesTicketsResult.rows[0]?.count || '0'),
|
|
seubertTickets: parseInt(seubertTicketsResult.rows[0]?.count || '0'),
|
|
universalTickets: parseInt(universalTicketsResult.rows[0]?.count || '0'),
|
|
};
|
|
|
|
return NextResponse.json(stats);
|
|
} catch (error) {
|
|
console.error('Error fetching kiosk stats:', error);
|
|
return NextResponse.json(
|
|
{ error: 'Failed to fetch kiosk stats' },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|