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:
lorentz 2026-03-17 07:39:55 -04:00
parent c518eefdb2
commit b98c67482a
40 changed files with 6223 additions and 15 deletions

552
LCI_FEATURE_DESIGN.md Normal file
View file

@ -0,0 +1,552 @@
# Lifecycle Insights Feature Replacement — Pulse Design Doc
> **Purpose**: Document how Lifecycle Insights (LCI) works across the four target features — Warranty, Assessments, Budget Forecast, and QBR/Business Reviews — so we can build equivalent functionality in Pulse, using Autotask (and Datto RMM) as the primary data source.
>
> **Reference client**: Hynes Industries (321 assets, multiple locations)
> **Source tool**: app.lifecycleinsights.io
> **Date captured**: 2026-03-16
---
## 1. Asset List & Warranty
### What LCI Shows
**Summary banner** (top of Asset List page):
- Total Assets: 321
- **EOL Status** breakdown:
- 104 Good Standing
- 60 Approaching EOL
- 6 Past EOL
- 151 Unknown
- **OS Status** breakdown:
- 177 Supported
- 27 Not Supported
- 117 Unknown
- **Warranty Status** breakdown:
- 91 Under Warranty
- 20 Approaching Warranty Expiration (within ~90 days)
- 97 Expired Warranty
- 113 Unknown
**Per-asset table columns**:
| Column | Example |
|--------|---------|
| Device Name | YNGHYNLT030 |
| Status | Active / Retired / Hidden |
| Last User (RMM) | HYNES\shopfloor |
| Location | Youngstown (Main) / Painesville (Branch) / Kokomo (Branch) |
| Type | Workstation / Hypervisor Host / Network Device |
| Make | Dell / Lenovo |
| Model | Latitude 5591 / ThinkSystem SR630 |
| Serial Number | 2KNXWT2 |
| Operating System | Microsoft Windows 10 Pro 10.0.19044 |
| Purchase Date | 04-23-2019 |
| Warranty Expiration | 04-24-2023 |
| End of Life | 04-23-2024 |
| Replacement Cost | $3,000 |
| Age (years) | 6.9 |
**Table features**:
- Sortable columns (click header)
- Filter buttons: Show Only NON Hidden / Duplicate / Hidden / All Assets
- Duplicate detection badge
- Pagination (321 rows, 25/50/75/100/200 per page)
- Search box
**Warranty status logic** (inferred):
- **Under Warranty**: warranty expiration date > today
- **Approaching**: warranty expiration within ~90 days
- **Expired**: warranty expiration date < today
- **Unknown**: no warranty expiration date on record
**EOL logic** (inferred):
- EOL date is OS/hardware specific (LCI maintains a lookup table — shown on dashboard)
- **Good Standing**: EOL date > today + threshold (~1 year)
- **Approaching EOL**: EOL date within ~1 year
- **Past EOL**: EOL date < today
- **Unknown**: no EOL date derivable
### Data Sources
- **Device inventory**: comes from RMM (Datto RMM in Hynes's case) — device name, make, model, serial, OS, last user, location
- **Purchase/warranty dates**: can come from RMM or be manually entered
- **EOL dates**: LCI maintains a global EOL lookup table by OS/software version (visible on dashboard — e.g., "Windows 10 Pro: 10-14-2025")
- **Replacement cost**: manually set per asset type, or inherited from category defaults
- **Locations**: mapped from RMM site groupings
### Pulse Implementation Notes
**Data model** (new migration needed):
```sql
-- assets table (core)
asset_id, autotask_id, datto_rmm_uid,
device_name, status, last_user,
location_id, asset_type,
make, model, serial_number,
os_name, os_version,
purchase_date, warranty_expiry_date, eol_date,
replacement_cost, age_years,
company_id (FK → companies),
is_hidden, is_duplicate,
created_at, updated_at, last_synced_at
-- eol_reference table (global)
software_name, eol_date
-- asset_locations table
location_id, company_id, location_name
```
**Warranty status computed field**:
```ts
function warrantyStatus(expiryDate: Date | null): 'under' | 'approaching' | 'expired' | 'unknown' {
if (!expiryDate) return 'unknown';
const daysUntil = differenceInDays(expiryDate, new Date());
if (daysUntil < 0) return 'expired';
if (daysUntil <= 90) return 'approaching';
return 'under';
}
```
**Autotask as source**: Autotask has a Configuration Items module that maps to devices. Datto RMM syncs device details into Autotask CIs. Fields available via Autotask API: `warrantyExpirationDate`, `purchaseOrderNumber`, `purchaseDate`, `installDate`, `serialNumber`, `referenceNumber`, manufacturer, product (model), location, deviceType.
---
## 2. Budget Forecast
### What LCI Shows
**Purpose**: Project technology spend for the next 68 quarters, broken into categories.
**Views**:
1. **Detail View** — collapsible sections per time period, category breakdown
2. **Spreadsheet View** — bar chart + grid with rows per category, columns per quarter
3. **Budget Board** — (not fully captured; likely a Kanban/card layout)
**Filters available**:
- Location (site filter)
- Asset Type
- Asset Status
- "Generate Budget Forecast as of" date (defaults to today; can run future projections)
**Time buckets**:
- Overdue (past-due items)
- Recommendations Not Scheduled (approved recommendations with no date)
- Then rolling quarters: e.g., Jan-Mar 2026, Apr-Jun 2026, Jul-Sep 2026, etc. (68 quarters shown)
**Categories**:
| Category | What it represents |
|----------|--------------------|
| Workstation | Assets reaching EOL scheduled for replacement |
| Hypervisor Host | Server/hypervisor assets reaching EOL |
| Network Device | Network hardware replacements |
| Contracts/Subscriptions | Contract renewals (from Autotask contracts) |
| Recommendations | Open recommendations with cost + target date |
**Hynes Industries example data** (Spreadsheet View):
| Category | Overdue | Not Sched | Apr-Jun 26 | Jul-Sep 26 | Oct-Dec 26 | Jan-Mar 27 | Apr-Jun 27 | Total |
|----------|---------|-----------|------------|------------|------------|------------|------------|-------|
| Hypervisor Host | $22,500 | — | — | — | — | — | — | $22,500 |
| Workstation | — | — | — | — | — | $18,000 | $21,000 | $39,000 |
| Contracts/Subscriptions | — | — | $37,231 | $43,590 | $164,414 | $27,737 | $37,231 | $313,549 |
| Recommendations | $254,754 | $107,000 | $31,245 | $15,045 | $29,795 | — | — | $437,839 |
| **TOTAL** | **$277,254** | **$107,000** | **$68,476** | **$58,635** | **$194,209** | **$45,737** | **$58,231** | **$812,888** |
### How Each Category is Populated
**Workstation / Hypervisor / Network Device**:
- Source: assets with EOL date falling within the quarter
- Cost: `replacement_cost` per asset
- Scheduled quarter = quarter containing the asset's `eol_date`
**Contracts/Subscriptions**:
- Source: Autotask Contracts with `end_date` or renewal dates
- Cost: contract value
- Quarter = quarter of contract end/renewal date
**Recommendations**:
- Source: open Recommendations (see Recommendations module)
- Cost: `estimated_cost` on the recommendation
- Quarter = recommendation's `planned_date` (or "Not Scheduled" if no date)
- "Overdue" = planned_date < today
### Pulse Implementation Notes
**Budget Forecast is computed at query time** — no separate stored table needed beyond assets + contracts + recommendations with dates and costs.
```sql
-- Pseudo-query for budget forecast
SELECT
quarter_bucket,
category,
COUNT(*) AS quantity,
SUM(cost) AS budget_cost
FROM (
-- EOL assets
SELECT date_trunc('quarter', eol_date) AS quarter_bucket,
asset_type AS category,
replacement_cost AS cost
FROM assets WHERE company_id = $1 AND eol_date IS NOT NULL AND status = 'active'
UNION ALL
-- Contracts
SELECT date_trunc('quarter', end_date), 'Contracts/Subscriptions', contract_value
FROM contracts WHERE company_id = $1
UNION ALL
-- Recommendations
SELECT date_trunc('quarter', COALESCE(planned_date, NULL)), 'Recommendations', estimated_cost
FROM recommendations WHERE company_id = $1 AND status NOT IN ('Completed', 'Declined')
) combined
GROUP BY quarter_bucket, category
ORDER BY quarter_bucket;
```
**API route**: `GET /api/budget-forecast/[companyId]?asOf=DATE&location=X&assetType=Y`
---
## 3. Assessments
### What LCI Shows
**Structure** (from Assessments Menu page):
1. **Assessment Events** — each time you run an assessment for a client = one "event"
2. **Assessment Templates** — define *what* you're measuring (items + categories)
3. **Assessment Items** — individual questions/checklist items
4. **Event Types** — classify events (e.g., baseline, annual review, gap assessment)
5. **Analytics by Customer** — score history, trends, category breakdowns
6. **Analytics All Customers** — cross-client comparison
**Wulf Consulting uses**: "Wulf Consulting: Default Assessment" template
**Hynes Industries history** (from dashboard):
- Latest: completed 01-29-2025 (411 days ago) by Jake Hammel
- Score type: "baseline assessment with a score of --" (baseline = no numeric score, just captures state)
- Assessment Event Score History chart shows one data point at 2025-01
**Data Quality Scores** (dashboard, related to assessments):
- EOL: 53/100
- Warranty: 65/100
- Make: 92/100
- Serial: 83/100
- Model: 95/100
- Overall: 67/100
### Assessment Data Model (inferred)
```
AssessmentTemplate
├── id, name, company_id (MSP-level)
├── items[] → AssessmentItem
│ ├── id, category, question_text, weight, scoring_type
│ └── possible_scores: [0,1,2,3,4,5] or pass/fail
└── event_type_config
AssessmentEvent
├── id, template_id, client_id (company)
├── event_type (baseline / annual / gap)
├── completed_date, completed_by (technician)
├── overall_score (numeric or null for baseline)
└── responses[] → AssessmentResponse
├── item_id, score, notes
└── linked_recommendation_id (optional)
AssessmentEventScore (for history/chart)
├── event_id, category, score, max_score
```
**Key behaviors**:
- Templates are MSP-defined (not per-client)
- One client can have many events over time (score history chart)
- Assessment items can generate Recommendations (linked)
- Scores tracked by category (enables radar/bar chart per category)
- "Baseline" events don't produce a numeric score — they capture current state only
### Pulse Implementation Notes
**Migration**: Create `assessment_templates`, `assessment_items`, `assessment_events`, `assessment_responses` tables.
**Admin UI**: Template builder (drag-drop items into categories), Event runner (per-client form), Analytics view (score history chart per client).
**Autotask link**: Assessment items that result in action → create Autotask ticket or recommendation.
---
## 4. Business Reviews / QBR
### What LCI Shows — FULLY CAPTURED
**Page**: `Business Review Meetings for Hynes Industries`
**Meeting table columns**:
| Column | Values |
|--------|--------|
| Meeting Date | e.g., 03-03-2026 |
| Meeting Type | Business Review / Virtual Meeting / (blank) |
| Primary Contact | Ryan Day |
| Account Manager | Jake Hammel |
| Status | Completed / Scheduled / Auto-Scheduled / Preparation / Skipped/Cxl |
| Notes | Rich text notes (Pre-Meeting / During Meeting / Post-Meeting sections) |
| Recommendations | (linked recommendations column) |
| Reports / Artifacts | (PDF download column) |
| Delete | delete action |
**Status types explained**:
- **Completed** — meeting happened and notes are finalized
- **Scheduled** — manually scheduled upcoming meeting
- **Auto-Scheduled** — system automatically created this slot based on cadence
- **Preparation** — notes are being drafted before meeting
- **Skipped/Cxl** — meeting was skipped or cancelled
**Cadence for Hynes**: Bi-weekly (every 2 weeks), Ryan Day as primary contact, Jake Hammel as account manager. Running since September 2023.
**Notes structure** (rich text, per meeting):
```
Pre-Meeting:
[prep notes, project status, talking points]
Meeting / During Meeting:
[live notes taken during call]
Post-Meeting:
[follow-up actions, decisions made]
```
**Real example notes** (from Hynes 09-19-2023):
> "Follow up with Ryan on 2024 CAPX Budget... Updated Ryan that moving forward new workstations won't have the BitLocker Password Prompt... Amy Young - Executive Admin - Get her to view details on the Hynes conference room calendars..."
**Scale**: Hynes has ~56 business review records from 09-2023 to 03-2026 (bi-weekly cadence = ~2/month × 30 months). The "47 overdue" from the dashboard is because many Auto-Scheduled meetings were never formally completed.
**Filters on page**:
- "Show Meetings with Date in Past" toggle
**Actions**:
- ADD NEW — create a new meeting record
- Each row has: delete, reports/artifacts (PDF generation)
### Business Reviews Data Model (confirmed)
```sql
business_reviews
id, company_id,
meeting_date DATE,
meeting_type VARCHAR, -- 'Business Review', 'Virtual Meeting', etc.
primary_contact_id INT, -- FK → contacts
account_manager_id INT, -- FK → users (MSP staff)
status VARCHAR, -- Completed/Scheduled/Auto-Scheduled/Preparation/Skipped-Cxl
notes_pre_meeting TEXT,
notes_during_meeting TEXT,
notes_post_meeting TEXT,
created_at, updated_at
business_review_artifacts -- linked PDFs / reports
id, business_review_id, artifact_type, file_url, created_at
business_review_recommendations -- join table
business_review_id, recommendation_id
```
**Key behaviors**:
- System auto-generates meeting slots based on cadence (configurable per client)
- Each meeting has pre/during/post note sections
- Meetings can link to specific recommendations (show progress at meeting)
- PDF reports can be attached or generated per meeting
- "47 overdue" = count of Auto-Scheduled meetings with date in past and no Completed status
- Agenda links (from dashboard widget) are separate from meeting notes — persistent links per client
### Pulse Implementation Notes
**QBR = structured meeting log** with recurring cadence + notes + linked recommendations.
- Auto-generate meeting slots via a cron job based on `meeting_cadence_days` per company
- The "Overdue" count = `SELECT COUNT(*) FROM business_reviews WHERE company_id = $1 AND meeting_date < NOW() AND status != 'Completed'`
- PDF generation: render current EOL health + budget + recommendations at time of export
- Agenda links widget: simple per-client URL bookmarks (`business_review_links` table)
---
## 5. Recommendations — FULLY CAPTURED
### What LCI Shows
**Page**: `Recommendations for Hynes Industries` — 26 total records
**Table columns**:
| Column | Notes |
|--------|-------|
| Name | Free-text project/recommendation name |
| Location | Youngstown (Main) / Painesville (Branch) / Kokomo (Branch) / blank (all sites) |
| Start | Target start date (optional) |
| End | Target end date (optional) |
| Status | New / Proposed / Planned / In Progress / On Hold / Completed / Declined |
| Tags | (free-form tags) |
| Priority | Low / Medium / High |
| Description | Rich text description of scope |
| Labor Cost | $ |
| Materials Cost | $ |
| Replacement Cost | $ |
| Recurring Cost | Monthly $X → shown as "Monthly / $X" |
| Total Cost | Sum of all cost components |
| Delete | delete action |
**Filters**:
- Search box
- Include Completed toggle
- Include Declined toggle
- "SHOW AUTOTASK OPPS" button — links to Autotask opportunities
**Views**: List (default) — table view
**Hynes Industries full recommendation list** (real data):
| Name | Status | Priority | Total Cost |
|------|--------|----------|------------|
| 2024 - Fish Bowl Renovation | Completed | Medium | $11,000 |
| 2024 - Pepwave Licensing Renewal | Completed | Medium | $0 |
| 2024 - Planned Multiple Switch Lifecycle | Completed | Medium | $24,000 |
| 2024 - Planned Workstation Lifecycle | In Progress | Medium | $9,800 |
| 2024 - VLAN Cleanup | Completed | Medium | $10,000 |
| 2024: O365 Backup | Completed | Medium | $3,560 (monthly) |
| 2024: Password Management | Completed | Medium | $8,340 (monthly) |
| 2024: SEIM Tool | Completed | High | $20,020 (monthly) |
| 2024: ZTNA - Phase 1 | Completed | High | $16,350 (monthly) |
| 2025 - Painesville Zoom Room | Declined | Low | $11,000 |
| 2025 - Planned Infrastructure Lifecycle | Completed | High | $6,830 (monthly) |
| 2025 - Planned Workstation Lifecycle | Completed | High | $3,000 |
| 2025 - PV Planned Multiple Switch Lifecycle | In Progress | High | $5,000 |
| 2025 - YNG Planned Multiple Switch Lifecycle | Completed | High | $2,900 |
| 2025: Windows 10 to 11 Upgrade | Completed | High | $40,400 |
| 2026 - Exterior Cameras - Partial Build | On Hold | Low | $57,000 |
| 2026 - PEN Test | On Hold | High | $20,000 |
| 2026 - Pepwave Licensing Renewal | Planned | High | $350 |
| 2026 - Planned Host/SAN Lifecycle | Proposed | High | $120,000 |
| 2026 - Planned Multiple Switch Lifecycle | Proposed | High | $31,000 |
| 2026 - Planned Workstation Budget | Proposed | Medium | $86,600 |
| 2026 - Zebra Printers Lifecycle | Proposed | High | $80,534 (monthly) |
| 2026: ZTNA: Phase 2 | On Hold | High | $30,000 |
| Auvik SaaS | Declined | Medium | $1,760 (monthly) |
| Physical Switch & IDF Cleanup | On Hold | Medium | $0 |
| Receptionist Hardware | Completed | Medium | $1,800 |
**How recommendations feed Budget Forecast**:
- Status `Proposed`, `Planned`, `In Progress`, `On Hold` → included in budget forecast
- `start` date (or `end` date) → determines which quarter they appear in
- No date → "Not Scheduled" bucket
- Past date → "Overdue" bucket
- `Completed` / `Declined` → excluded from forecast
### Recommendations Data Model
```sql
recommendations
id, company_id,
name VARCHAR,
location_id INT (nullable),
start_date DATE (nullable),
end_date DATE (nullable),
status VARCHAR, -- New/Proposed/Planned/In Progress/On Hold/Completed/Declined
tags TEXT[],
priority VARCHAR, -- Low/Medium/High
description TEXT,
labor_cost DECIMAL,
materials_cost DECIMAL,
replacement_cost DECIMAL,
recurring_cost DECIMAL, -- monthly amount
recurring_period VARCHAR, -- 'Monthly'
total_cost DECIMAL, -- computed or stored
autotask_opportunity_id INT (nullable),
created_at, updated_at
recommendation_assets -- assets linked to this recommendation
recommendation_id, asset_id
```
### Pulse Implementation Notes
- Recommendations are **Pulse-native** (not pulled from Autotask) but can push/link to Autotask Opportunities
- `total_cost = labor_cost + materials_cost + replacement_cost + (recurring_cost × months_in_period)`
- Budget Forecast query filters: `status NOT IN ('Completed', 'Declined')`
- Dashboard widget shows count + total cost grouped by status
- The `SHOW AUTOTASK OPPS` button = link to Autotask opportunities filtered to this company
---
## 6. Dashboard Summary
The LCI dashboard aggregates all four features for a client into one view:
| Widget | Data |
|--------|------|
| CSAT Score | From Crewhu integration (external) |
| Budget Forecast chart | 6-quarter bar chart |
| EOL Health | % OK / Past EOL / Near EOL donut |
| Data Quality Score | Per-field completeness scores |
| Microsoft License Summary | From M365 / CSP data |
| EOL Replacement Cost | Bar chart by status |
| Latest Assessment | Score + date |
| Recommendation Status | Count + cost per status |
| Assessment Score History | Line/bar chart |
| Software/OS EOL Table | Global reference table |
| Tech Debt by Asset Type | Stacked bar |
| Missing Data Fields | Counts of missing fields |
| Total Technology Debt | $ value of overdue/EOL assets |
| Headlines | Alerts: overdue contracts, overdue QBRs, latest assessment age |
---
## 6. Integrations & Data Flow
```
Datto RMM ──────────────────┐
Autotask CIs ───────────► LCI Asset Sync
Autotask Contracts ──────────┤
Autotask Recommendations ────┤
(or LCI-native) │
LCI PostgreSQL
┌────────┼────────┐
▼ ▼ ▼
Budget Assessments QBR
Forecast Events Reports
```
**For Pulse**:
- Assets: Autotask Configuration Items API → `assets` table
- Contracts: Autotask Contracts API → `contracts` table (already partially done)
- Recommendations: Either Autotask Opportunities/Quotes or Pulse-native table
- CSAT: Not currently integrated (Crewhoo is external) — can be skipped initially
- EOL Reference: Static lookup table (maintained manually or seeded from LCI's public list)
- Warranty dates: Come from Autotask CI `warrantyExpirationDate` field
---
## 7. Build Priority Order
1. **Asset List + Warranty** — highest value, data already in Autotask CIs
2. **Budget Forecast** — derived from assets + contracts (both already syncing)
3. **QBR / Business Reviews** — report generation + scheduling
4. **Assessments** — most complex, requires template builder + event runner
---
## 9. Pages Still to Explore
**Fully captured**: Asset List, Budget Forecast (detail + spreadsheet), Recommendations (all 26), Business Reviews (all ~56 meetings), Dashboard.
**Still needed for complete picture**:
- `app.lifecycleinsights.io/assessmentevents` — see an actual scored assessment event (items, categories, scores per item). The Hynes event from 01-29-2025 was a baseline with no numeric score.
- `app.lifecycleinsights.io/assessmentanalytics` — analytics/trends view per client
- `app.lifecycleinsights.io/managetemplates` — what a template looks like with items + categories
- MDS client — verify data model generalizes (different asset mix, different contact structure)
These can be explored via Claude_in_Chrome (already connected to local Chrome where you're logged in) without needing another MFA login.

525
PULSE_DATABASE_SKILL.md Normal file
View file

@ -0,0 +1,525 @@
# Pulse Database Skill — Query Reference
> **Purpose:** This document describes the PostgreSQL database behind **Pulse**, an MSP operations platform built by Wulf Consulting. Use it to query Autotask PSA data, RMM alerts, security agents, backup status, IT documentation, engagement metrics, and more.
## Connection
- **Host:** `pulse-postgres` (Docker) or `localhost:5432`
- **Database:** `pulse_autotask`
- **User:** `pulse_user`
- **Read-only queries only** — no INSERT/UPDATE/DELETE
---
## Data Domains at a Glance
| Domain | Key Tables | Approx Rows | Description |
|---|---|---|---|
| **Autotask PSA** | tickets, time_entries, companies, contacts, resources, configuration_items, contracts, projects, tasks, ticket_notes | 109K tickets, 154K time entries, 7K CIs | Service desk, billing, contracts, clients |
| **Datto RMM** | datto_rmm_alerts, datto_rmm_devices, datto_rmm_sites | 4.3K alerts, 3.6K devices | Remote monitoring & management |
| **SentinelOne** | s1_agents, s1_threats, s1_sites | 2.8K agents, 4.1K threats | Endpoint security |
| **Veeam** | veeam_organizations, veeam_backup_jobs, veeam_backup_agents, veeam_alarms, veeam_protected_workloads, veeam_repositories, veeam_backup_servers | ~2.5K total | Backup & disaster recovery |
| **IT Glue** | itg_organizations, itg_configurations, itg_passwords, itg_flexible_assets, itg_contacts, itg_documents, itg_expirations, itg_domains, itg_locations | 14.7K configs, 7.1K contacts | IT documentation |
| **Microsoft 365** | graph_users, teams_meetings, teams_meeting_attendees, engagement_snapshots | 14.3K meetings | Teams meetings, activity reports |
| **Zoom** | zoom_users, zoom_meetings, zoom_meeting_participants, zoom_calls | 2.8K calls, meetings | Zoom calls and meetings |
| **Billing** | billing_items | 95K items | Invoice line items tied to tickets/projects/tasks |
---
## 1. Autotask PSA — Core Service Desk
### tickets (~109K rows, 68 columns)
The central table. Each row is a service ticket.
**Key columns:**
- `id` (bigint PK) — Autotask ticket ID
- `title` (varchar) — ticket subject line
- `description` (text) — full body/description
- `status` (int) — FK to `statuses.value`
- `priority` (int) — FK to `priorities.value`
- `queue_id` (int) — FK to `queues.value`
- `source` (int) — how the ticket was created (see Source Codes below)
- `company_id` (bigint) — FK to `companies.id`
- `contact_id` (bigint) — FK to `contacts.id`
- `assigned_resource_id` (bigint) — FK to `resources.id`
- `configuration_item_id` (bigint) — FK to `configuration_items.id`
- `contract_id` (bigint) — FK to `contracts.id`
- `project_id` (bigint) — FK to `projects.id`
- `issue_type` (int), `sub_issue_type` (int) — classification
- `ticket_type` (int) — 1=Incident, 2=Service Request, 5=Alert
- `create_date` (timestamp) — when opened
- `due_date_time` (timestamp) — SLA due
- `completed_date` (timestamp) — when closed
- `resolved_date_time` (timestamp) — when resolved
- `first_response_date_time` (timestamp) — first response SLA timestamp
- `last_activity_date` (timestamp) — most recent update
- `monitor_id` (bigint) — RMM monitor that created this ticket (if source=8)
- `monitor_type_id` (int) — type of monitor
- `is_deleted` (boolean) — soft delete flag
**Source codes (tickets.source):**
| Value | Meaning | Count |
|---|---|---|
| 8 | Monitoring Alert (RMM/Datto) | 80,736 |
| 4 | Email | 14,872 |
| 21 | Portal | 2,500 |
| 2 | Phone/Voice | 1,781 |
| -1 | Insourced | 1,092 |
| -2 | Outsourced | 1,088 |
| 35 | Phish Alert | 726 |
| 6 | API | 488 |
| 17 | Internal Alert | 453 |
**Ticket types:**
| Value | Meaning | Count |
|---|---|---|
| 1 | Incident | 8,409 |
| 2 | Service Request | 3,217 |
| 5 | Alert | 29,885 |
| NULL | Unclassified | 67,737 |
### companies (~4K rows, 41 columns)
Client/customer organizations.
**Key columns:**
- `id` (bigint PK)
- `company_name` (varchar) — display name
- `company_number` (varchar) — short code
- `is_active` (boolean)
- `company_type` (int) — 1=Customer, 2=Lead, 3=Prospect, 4=Dead, 6=Cancellation, 7=Vendor, etc.
- `owner_resource_id` (bigint) — account manager, FK to `resources.id`
- `classification` (varchar) — e.g. "Platinum", "Gold", etc.
- Address fields: `address1`, `city`, `state`, `postal_code`
- `last_activity_date` (timestamp)
### contacts (~4.2K rows, 37 columns)
People at client companies.
**Key columns:**
- `id` (bigint PK)
- `first_name`, `last_name`, `email_address`, `phone` (varchar)
- `company_id` (bigint) — FK to `companies.id`
- `is_active` (boolean)
- `title` (varchar) — job title
### resources (~40 columns)
Internal staff / technicians.
**Key columns:**
- `id` (bigint PK)
- `first_name`, `last_name`, `email` (varchar)
- `email_address` (varchar) — primary email
- `is_active` (boolean)
- `resource_type` (varchar)
- `default_service_desk_role_id` (bigint)
- `hire_date` (date)
- `location_id` (bigint)
### time_entries (~154K rows, 45 columns)
Work logged against tickets, tasks, or projects.
**Key columns:**
- `id` (bigint PK)
- `resource_id` (bigint) — who did the work, FK to `resources.id`
- `ticket_id` (bigint) — FK to `tickets.id` (NULL if task/project entry)
- `task_id` (bigint) — FK to `tasks.id`
- `project_id` (bigint) — FK to `projects.id`
- `company_id` (bigint) — FK to `companies.id`
- `entry_date` (timestamp) — date of work
- `hours_worked` (numeric) — actual hours
- `hours_to_bill` (numeric) — billable hours
- `start_date_time`, `end_date_time` (timestamp) — clock in/out
- `title` (varchar), `notes` (text), `internal_notes` (text)
- `billable` (boolean), `non_billable` (boolean)
- `billing_rate` (numeric), `cost_rate` (numeric), `revenue` (numeric)
- `contract_id` (bigint), `contract_service_id` (bigint)
- `role_id` (bigint)
- `is_deleted` (boolean)
### ticket_notes (~30K rows, 14 columns)
Notes/comments on tickets.
**Key columns:**
- `id` (bigint PK)
- `ticket_id` (bigint) — FK to `tickets.id`
- `title` (varchar), `description` (text) — note content
- `note_type` (int) — internal, external, etc.
- `publish` (int) — visibility
- `creator_resource_id` (bigint) — who wrote it
- `create_date_time` (timestamptz)
### configuration_items (~7K rows, 95 columns)
Devices/assets tracked in Autotask.
**Key columns:**
- `id` (bigint PK)
- `reference_title` (varchar) — device name (e.g. "DT037", "SRV-DC01")
- `reference_number` (varchar) — often a GUID from RMM
- `serial_number` (varchar)
- `company_id` (bigint) — FK to `companies.id`
- `contact_id` (bigint) — FK to `contacts.id`
- `is_active` (boolean)
- `device_type` (varchar)
**Note:** `reference_title` follows a naming convention per client (e.g. DT037 exists at multiple companies as separate CIs). Always filter by both `reference_title` AND `company_id` when searching.
### contracts (~44 columns)
Service agreements with clients.
**Key columns:**
- `id` (bigint PK)
- `company_id` (bigint) — FK to `companies.id`
- `contract_name` (varchar), `contract_number` (varchar)
- `contract_type` (int), `status` (int)
- `start_date`, `end_date` (date)
- `estimated_hours` (numeric), `estimated_revenue` (numeric)
### contract_services (~8.2K rows)
Line items on contracts.
- `contract_id``contracts.id`
- `company_id``companies.id`
- `service_name` (text), `unit_price`, `quantity`
### projects (~291 rows, 36 columns)
**Key columns:**
- `id`, `company_id`, `project_name`, `status`, `type`
- `project_lead_resource_id``resources.id`
- `start_date_time`, `end_date_time`, `actual_hours`, `estimated_time`
### tasks (~4.3K rows, 34 columns)
Tasks on tickets or projects.
- `ticket_id``tickets.id`
- `project_id``projects.id`
- `assigned_resource_id``resources.id`
- `status`, `priority`, `estimated_hours`, `remaining_hours`
### billing_items (~96K rows)
Invoice line items linked to tickets, tasks, or projects.
- `ticket_id``tickets.id`, `task_id``tasks.id`, `project_id``projects.id`
- `company_id``companies.id`
- `quantity`, `rate`, `total_amount`, `unit_cost`, `unit_price`
---
## 2. Lookup / Picklist Tables
These map integer codes to human-readable labels. Join on `value`.
### statuses (ticket statuses)
| Value | Label |
|---|---|
| 1 | New |
| 5 | Complete |
| 7 | Waiting Customer |
| 8 | In Progress |
| 10 | Dispatched |
| 11 | Escalate |
| 12 | Waiting Vendor |
| 13 | Waiting Approval |
| 14 | Resource Assigned |
| 16 | Reopened |
| 19 | End User Note Added |
| 25 | On Hold |
| 48 | Escalate to MC |
| 54 | Resolved \<CSAT Survey\> |
| 57 | Escalate to Wulf |
### priorities
| Value | Label |
|---|---|
| 1 | Standard |
| 2 | Medium |
| 4 | Critical |
| 6 | High |
| 7 | Security Event |
| 8 | Minor Service |
| 9 | Major Service |
| 11 | Fast Track |
### queues (46 active)
Major queues include:
- `29682833` Level 1 Support
- `29682969` Level 2 Support
- `29703428` Level 3 Support
- `29749490` Client Success
- `8` Monitoring Alert
- `29832283` Operations Triage
- `5` Client Triage
- `29853700` Deployment
- `29853698` Project Delivery
- `29853701` IT Operations
- `29853699` Mission Control
- Client-specific queues: TTG, LEC, PER, VCF, Premier Automation, TNT Pizza, Trivium Packaging, Glunt
---
## 3. Datto RMM
### datto_rmm_alerts (~4.3K rows, 61 columns)
- `id` (int PK), `uid` (text) — alert identifiers
- `alert_category`, `alert_type`, `alert_message_en` — what triggered
- `priority` (text) — Critical, High, Moderate, Low, Information
- `resolved` (boolean), `resolved_on` (timestamptz)
- `muted` (boolean)
- `ticket_number` (text) — linked Autotask ticket
- `device_hostname`, `device_ip`, `device_os`, `device_id`
- `site_id` (text) — FK to `datto_rmm_sites`
- `timestamp` (timestamptz) — when alert fired
### datto_rmm_devices (~3.6K rows, 42 columns)
- `id` (int PK), `uid` (text), `hostname`
- `device_type_category` (text) — Server, Desktop, Laptop, Network Device
- `operating_system`, `domain`, `int_ip_address`, `ext_ip_address`
- `online` (boolean), `last_seen` (timestamptz)
- `last_logged_in_user` (text)
- `antivirus_product`, `antivirus_status`, `patch_status`
- `site_id` (int) — FK to `datto_rmm_sites.id`
- `udf` (jsonb) — custom fields
### datto_rmm_sites (~16 columns)
- `id` (int PK), `uid`, `name`
- `autotask_company_id` (int) — **FK to `companies.id`** (links RMM sites to Autotask clients)
- `autotask_company_name`
- `number_of_devices`, `number_of_online_devices`
**Join pattern:** `datto_rmm_sites.autotask_company_id = companies.id`
---
## 4. SentinelOne
### s1_agents (~2.8K rows, 42 columns)
Endpoint security agents.
- `id` (varchar PK) — S1 agent ID
- `computer_name`, `os_name`, `os_type`
- `site_id``s1_sites.id`, `site_name`
- `is_active`, `is_decommissioned`
- `infected` (boolean), `active_threats` (int)
- `network_status`, `mitigation_mode`, `detection_state`
- `external_ip`, `last_active_date`, `last_logged_in_user_name`
- `firewall_enabled` (boolean)
### s1_threats (~4.1K rows, 25 columns)
Detected threats.
- `id` (varchar PK)
- `threat_name`, `classification`, `confidence_level`
- `mitigation_status`, `analyst_verdict`, `incident_status`
- `agent_id``s1_agents.id`
- `agent_computer_name`, `agent_os_name`
- `site_id``s1_sites.id`
### s1_sites (~22 columns)
- `id` (varchar PK), `name`, `account_name`
- `health_status`, `active_licenses`, `total_licenses`
### s1_company_mappings
Maps S1 sites to Autotask companies for cross-referencing.
---
## 5. Veeam Backup
### veeam_organizations (~17 columns)
- `instance_uid` (PK), `name`, `company_id`
- All other Veeam tables FK to `veeam_organizations.instance_uid`
### veeam_backup_jobs (~234 rows)
- `instance_uid`, `name`, `type`, `status`, `last_run`, `next_run`
- `organization_uid``veeam_organizations`
- `backup_server_uid``veeam_backup_servers`
### veeam_backup_agents (~727 rows)
- Backup agents installed on endpoints
- `organization_uid``veeam_organizations`
### veeam_alarms (~581 rows)
- Active alarms/alerts
- `organization_uid``veeam_organizations`
### veeam_protected_workloads, veeam_repositories, veeam_backup_servers
Supporting tables for backup infrastructure.
---
## 6. IT Glue Documentation
### itg_organizations (~330 rows)
- `id` (bigint PK), `name`, `short_name`, `organization_type_name`
- `psa_id` (varchar) — Autotask company ID (string). Join: `itg_organizations.psa_id::bigint = companies.id`
### itg_configurations (~14.7K rows)
Hardware/software assets documented in IT Glue.
- `id`, `organization_id``itg_organizations.id`
- `name`, `hostname`, `serial_number`, `asset_tag`
- `configuration_type_name`, `configuration_status_name`
- `primary_ip`, `mac_address`, `operating_system`
- `warranty_expires_at`, `installed_at`
### itg_passwords (~17 columns)
- `id`, `organization_id`, `name`, `username`, `password_category_name`
- `url`, `notes`
### itg_flexible_assets (~3.2K rows)
Custom documentation (e.g. Backup configs, Email configs, LAN/VLAN, Voice/PBX).
- `id`, `organization_id`, `flexible_asset_type_id`, `flexible_asset_type_name`
- `traits` (jsonb) — all custom field values
### itg_contacts, itg_documents, itg_expirations, itg_domains, itg_locations
Supporting IT documentation tables.
---
## 7. Engagement & Communications
### graph_users
Microsoft 365 users synced from Azure AD.
- `id` (varchar PK), `display_name`, `email`, `job_title`, `department`
- `account_enabled` (boolean)
### teams_meetings (~14.3K rows)
Teams calendar events / meetings.
- `id` (int PK), `user_email`, `subject`
- `start_time`, `end_time` (timestamptz), `duration_minutes`
- `attendee_count`, `client_attendee_count`, `has_client_attendees` (boolean)
### teams_meeting_attendees (~12.6K rows)
- `meeting_id``teams_meetings.id`
- `attendee_email`, `attendee_name`
- `matched_contact_id``contacts.id`
- `matched_company_id``companies.id`
### engagement_snapshots (~1K rows)
Weekly/monthly aggregates of M365 activity per user.
- `user_email`, `period_type` (D7, D30, D90, D180)
- `teams_chat_messages`, `teams_calls`, `teams_meetings_attended`, `teams_meetings_organized`
- `emails_sent`, `emails_received`, `emails_read`
### zoom_meetings, zoom_meeting_participants
- `host_email`, `topic`, `start_time`, `end_time`, `duration_minutes`
- Participants with `matched_contact_id``contacts.id`, `matched_company_id``companies.id`
- `is_internal` (boolean) — internal vs external attendee
### zoom_calls (~2.8K rows)
- `resource_email`, `direction` (inbound/outbound), `call_status`
- `other_party_number`, `other_party_name`
- `matched_contact_id`, `matched_company_id`
---
## 8. Common Join Patterns
```sql
-- Ticket with company, resource, and status label
SELECT t.id, t.title, c.company_name,
r.first_name || ' ' || r.last_name AS technician,
s.label AS status_label, p.label AS priority_label
FROM tickets t
LEFT JOIN companies c ON c.id = t.company_id
LEFT JOIN resources r ON r.id = t.assigned_resource_id
LEFT JOIN statuses s ON s.value = t.status
LEFT JOIN priorities p ON p.value = t.priority
WHERE t.is_deleted IS NOT TRUE;
-- Time entries for a ticket
SELECT te.entry_date, te.hours_worked, te.notes,
r.first_name || ' ' || r.last_name AS technician
FROM time_entries te
JOIN resources r ON r.id = te.resource_id
WHERE te.ticket_id = $1 AND te.is_deleted IS NOT TRUE;
-- RMM device → Autotask company
SELECT d.hostname, d.device_type_category, d.operating_system,
s.autotask_company_name, d.online, d.last_seen
FROM datto_rmm_devices d
JOIN datto_rmm_sites s ON s.id = d.site_id;
-- Config item lookup (always filter by company too)
SELECT ci.id, ci.reference_title, ci.serial_number, c.company_name
FROM configuration_items ci
JOIN companies c ON c.id = ci.company_id
WHERE ci.reference_title = 'DT037' AND ci.company_id = $1;
-- IT Glue org → Autotask company
SELECT ig.name, ig.id AS itg_org_id, c.id AS autotask_company_id, c.company_name
FROM itg_organizations ig
JOIN companies c ON ig.psa_id::bigint = c.id;
-- Meetings with client attendees
SELECT tm.subject, tm.start_time, tm.duration_minutes,
tma.attendee_name, c.company_name
FROM teams_meetings tm
JOIN teams_meeting_attendees tma ON tma.meeting_id = tm.id
LEFT JOIN companies c ON c.id = tma.matched_company_id
WHERE tm.has_client_attendees = true;
```
---
## 9. Important Notes
1. **Soft deletes:** Most Autotask tables have `is_deleted` (boolean) and `deleted_at`. Always add `WHERE is_deleted IS NOT TRUE` unless you want deleted records.
2. **Picklist joins:** `status`, `priority`, `queue_id`, `source` on tickets are integer codes. Join to `statuses`, `priorities`, `queues` on `.value` for labels.
3. **Configuration item names are NOT unique globally.** Names like "DT037" are a per-client naming convention. Always pair with `company_id`.
4. **Timestamps:** Most Autotask timestamps are `timestamp without time zone` stored in UTC. Teams/Zoom timestamps are `timestamp with time zone`.
5. **Monitor tickets:** `tickets.source = 8` indicates RMM-generated tickets. `monitor_id` links to the specific Datto RMM monitor. These represent ~74% of all tickets.
6. **Cross-platform linking:**
- RMM → Autotask: `datto_rmm_sites.autotask_company_id = companies.id`
- IT Glue → Autotask: `itg_organizations.psa_id::bigint = companies.id`
- S1 → Autotask: via `s1_company_mappings`
- Zoom/Teams → Contacts: `matched_contact_id` / `matched_company_id` columns
- Config Items → RMM: `configuration_items.reference_number` sometimes matches RMM device UIDs
7. **Row counts** (as of March 2026): tickets 109K, time_entries 154K, billing_items 96K, ticket_notes 30K, teams_meetings 14K, itg_configurations 15K, configuration_items 7K, companies 4K, contacts 4.2K, datto_rmm_devices 3.6K, datto_rmm_alerts 4.3K, s1_agents 2.8K, s1_threats 4.1K, zoom_calls 2.8K.

246
app/admin/qbo/page.tsx Normal file
View file

@ -0,0 +1,246 @@
'use client';
import { useState, useEffect, useCallback, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/button';
import {
CheckCircle2, XCircle, AlertTriangle, RefreshCw, Loader2,
Link2, Link2Off, FileText, CreditCard, Building2, ArrowDownToLine, BarChart3,
} from 'lucide-react';
interface QboStatus {
tokenStatus: 'valid' | 'expired' | 'missing';
counts: {
invoices: number;
payments: number;
deposits: number;
transactions: number;
reports: number;
};
lastSync: {
invoices: string | null;
payments: string | null;
deposits: string | null;
transactions: string | null;
reports: string | null;
};
}
function fmtDate(d: string | null) {
if (!d) return 'Never';
const date = new Date(d);
const diff = Date.now() - date.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function fmtNum(n: number) {
return n.toLocaleString();
}
const ENTITY_META = [
{ key: 'invoices', label: 'Invoices', icon: FileText, color: 'text-blue-400' },
{ key: 'payments', label: 'Payments', icon: CreditCard, color: 'text-green-400' },
{ key: 'deposits', label: 'Deposits', icon: Building2, color: 'text-purple-400' },
{ key: 'transactions', label: 'Transactions', icon: ArrowDownToLine, color: 'text-orange-400' },
{ key: 'reports', label: 'Reports', icon: BarChart3, color: 'text-cyan-400' },
] as const;
function QboPageInner() {
const searchParams = useSearchParams();
const connected = searchParams.get('connected');
const disconnected = searchParams.get('disconnected');
const errorParam = searchParams.get('error');
const [status, setStatus] = useState<QboStatus | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [syncMessage, setSyncMessage] = useState<string | null>(null);
const [banner, setBanner] = useState<{ type: 'success' | 'error' | 'info'; msg: string } | null>(null);
const fetchStatus = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/qbo/sync');
const data = await res.json();
setStatus(data);
} catch {
setStatus(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
useEffect(() => {
if (connected === 'true') setBanner({ type: 'success', msg: 'QuickBooks Online connected successfully.' });
else if (disconnected === 'true') setBanner({ type: 'info', msg: 'QuickBooks Online disconnected.' });
else if (errorParam) setBanner({ type: 'error', msg: decodeURIComponent(errorParam) });
}, [connected, disconnected, errorParam]);
async function triggerSync(syncType: 'full' | 'incremental') {
setSyncing(true);
setSyncMessage(null);
try {
const res = await fetch('/api/qbo/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncType, triggeredBy: 'admin-ui' }),
});
const data = await res.json();
if (res.ok) {
setSyncMessage(`${syncType === 'full' ? 'Full' : 'Incremental'} sync started. This may take a few minutes.`);
setTimeout(() => fetchStatus(), 10000);
setTimeout(() => fetchStatus(), 30000);
setTimeout(() => { fetchStatus(); setSyncing(false); }, 60000);
} else {
setSyncMessage(`Error: ${data.error}`);
setSyncing(false);
}
} catch (err) {
setSyncMessage(`Error: ${err instanceof Error ? err.message : String(err)}`);
setSyncing(false);
}
}
const tokenOk = status?.tokenStatus === 'valid';
const tokenBadge = {
valid: { icon: CheckCircle2, label: 'Connected', cls: 'text-green-400' },
expired: { icon: AlertTriangle, label: 'Token Expired', cls: 'text-yellow-400' },
missing: { icon: XCircle, label: 'Not Connected', cls: 'text-red-400' },
}[status?.tokenStatus ?? 'missing'];
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">QuickBooks Online</h1>
<p className="text-muted-foreground text-sm mt-1">Sync invoices, payments, deposits, transactions and financial reports</p>
</div>
<Button variant="outline" size="sm" onClick={fetchStatus} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Banner */}
{banner && (
<div className={`flex items-center gap-3 px-4 py-3 rounded-lg border text-sm ${
banner.type === 'success' ? 'bg-green-500/10 border-green-500/30 text-green-300' :
banner.type === 'error' ? 'bg-red-500/10 border-red-500/30 text-red-300' :
'bg-blue-500/10 border-blue-500/30 text-blue-300'
}`}>
{banner.type === 'success' ? <CheckCircle2 className="w-4 h-4 shrink-0" /> :
banner.type === 'error' ? <XCircle className="w-4 h-4 shrink-0" /> :
<AlertTriangle className="w-4 h-4 shrink-0" />}
{banner.msg}
<button className="ml-auto opacity-60 hover:opacity-100" onClick={() => setBanner(null)}></button>
</div>
)}
{/* Connection Status */}
<div className="rounded-xl border bg-card p-5 space-y-4">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base">Connection Status</h2>
{loading ? (
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
) : (
<div className={`flex items-center gap-1.5 text-sm font-medium ${tokenBadge.cls}`}>
<tokenBadge.icon className="w-4 h-4" />
{tokenBadge.label}
</div>
)}
</div>
<div className="flex gap-3 flex-wrap">
<a href="/api/qbo/auth">
<Button variant="outline" size="sm" className="gap-2">
<Link2 className="w-4 h-4" />
{tokenOk ? 'Reconnect' : 'Connect to QuickBooks'}
</Button>
</a>
{tokenOk && (
<a href="/api/qbo/disconnect">
<Button variant="outline" size="sm" className="gap-2 text-red-400 border-red-500/30 hover:bg-red-500/10">
<Link2Off className="w-4 h-4" />
Disconnect
</Button>
</a>
)}
</div>
</div>
{/* Sync Controls */}
<div className="rounded-xl border bg-card p-5 space-y-4">
<h2 className="font-semibold text-base">Sync</h2>
<div className="flex gap-3 flex-wrap">
<Button
onClick={() => triggerSync('full')}
disabled={syncing || !tokenOk}
size="sm"
className="gap-2"
>
{syncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Full Sync
</Button>
<Button
onClick={() => triggerSync('incremental')}
disabled={syncing || !tokenOk}
variant="outline"
size="sm"
className="gap-2"
>
{syncing ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
Incremental Sync
</Button>
</div>
{syncMessage && (
<p className="text-sm text-muted-foreground">{syncMessage}</p>
)}
</div>
{/* Entity Counts */}
<div className="rounded-xl border bg-card p-5 space-y-4">
<h2 className="font-semibold text-base">Synced Data</h2>
{loading ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="w-4 h-4 animate-spin" /> Loading...
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4">
{ENTITY_META.map(({ key, label, icon: Icon, color }) => (
<div key={key} className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border">
<Icon className={`w-5 h-5 shrink-0 ${color}`} />
<div>
<div className="text-lg font-semibold leading-none">
{fmtNum(status?.counts[key] ?? 0)}
</div>
<div className="text-xs text-muted-foreground mt-0.5">{label}</div>
<div className="text-xs text-muted-foreground/60 mt-0.5">
{fmtDate(status?.lastSync[key] ?? null)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
export default function QboAdminPage() {
return (
<Suspense fallback={<div className="p-6 text-muted-foreground text-sm">Loading...</div>}>
<QboPageInner />
</Suspense>
);
}

View file

@ -0,0 +1,456 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Send, RefreshCw, CheckCircle2, XCircle,
Clock, Loader2, ChevronDown, ChevronUp, BarChart3, Brain,
Calendar, CalendarDays, CalendarRange, MessageSquare, Bell, Globe, ExternalLink,
} from 'lucide-react';
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[];
}
interface NotificationChannel {
id: number;
name: string;
channel_type: 'teams' | 'telegram' | 'ntfy' | 'webhook';
config: Record<string, any>;
is_active: boolean;
}
interface DigestReport {
id: number;
period_type: string;
period_start: string;
period_end: string;
generated_at: string;
stats: any;
llm_analysis: string | null;
delivery_status: Record<string, { success: boolean; httpStatus?: number; error?: string }>;
tokens_used: number | null;
processing_time_ms: number | null;
}
function fmtDate(d: string | null) {
if (!d) return 'Never';
const date = new Date(d);
const diff = Date.now() - date.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'Just now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function ChannelIcon({ type }: { type: string }) {
if (type === 'teams') return <MessageSquare className="h-4 w-4 text-indigo-500" />;
if (type === 'telegram') return <Send className="h-4 w-4 text-blue-500" />;
if (type === 'ntfy') return <Bell className="h-4 w-4 text-green-500" />;
return <Globe className="h-4 w-4 text-gray-500" />;
}
function PeriodIcon({ period }: { period: string }) {
if (period === 'daily') return <Calendar className="h-4 w-4 text-blue-500" />;
if (period === 'weekly') return <CalendarDays className="h-4 w-4 text-purple-500" />;
return <CalendarRange className="h-4 w-4 text-orange-500" />;
}
export default function TicketDigestPage() {
const [config, setConfig] = useState<DigestConfig | null>(null);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [history, setHistory] = useState<DigestReport[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null);
const [expandedReport, setExpandedReport] = useState<number | null>(null);
const [previewData, setPreviewData] = useState<any>(null);
const [previewPeriod, setPreviewPeriod] = useState<string | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const showToast = useCallback((msg: string, ok: boolean) => {
setToast({ msg, ok });
setTimeout(() => setToast(null), 4000);
}, []);
const loadData = useCallback(async () => {
try {
const res = await fetch('/api/reports/ticket-digest');
const data = await res.json();
setConfig(data.config);
setChannels(data.channels || []);
setHistory(data.history || []);
} catch (e) {
showToast('Failed to load data', false);
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => { loadData(); }, [loadData]);
const generateReport = async (period: string) => {
setGenerating(period);
try {
const res = await fetch('/api/reports/ticket-digest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ period }),
});
const data = await res.json();
if (data.success) {
showToast(`${period} digest generated (${data.processingTimeMs}ms)`, true);
loadData();
} else {
showToast(data.error || 'Generation failed', false);
}
} catch (e) {
showToast('Network error', false);
} finally {
setGenerating(null);
}
};
const loadPreview = async (period: string) => {
if (previewPeriod === period) { setPreviewPeriod(null); setPreviewData(null); return; }
setPreviewLoading(true);
setPreviewPeriod(period);
try {
const res = await fetch(`/api/reports/ticket-digest?preview=${period}`);
const data = await res.json();
setPreviewData(data.stats);
} catch {
showToast('Failed to load preview', false);
} finally {
setPreviewLoading(false);
}
};
const updateConfig = async (updates: Partial<DigestConfig>) => {
try {
const res = await fetch('/api/reports/ticket-digest/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const data = await res.json();
setConfig(data.config);
showToast('Config updated', true);
} catch {
showToast('Failed to update config', false);
}
};
const toggleChannelId = async (id: number) => {
if (!config) return;
const current = config.channel_ids || [];
const updated = current.includes(id)
? current.filter(c => c !== id)
: [...current, id];
await updateConfig({ channel_ids: updated });
};
if (loading) return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
return (
<div className="container mx-auto py-8 px-4 max-w-4xl space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg text-sm text-white ${toast.ok ? 'bg-green-600' : 'bg-red-600'}`}>
{toast.msg}
</div>
)}
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<BarChart3 className="h-6 w-6" /> Ticket Digest Reports
</h1>
<p className="text-muted-foreground text-sm mt-1">
LLM-analyzed ticket reports delivered to Teams daily, weekly, and monthly
</p>
</div>
<Button variant="outline" size="sm" onClick={loadData}>
<RefreshCw className="h-4 w-4 mr-1" /> Refresh
</Button>
</div>
{/* Generate Reports */}
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg flex items-center gap-2">
<Brain className="h-5 w-5" /> Generate Report
</h2>
<div className="grid grid-cols-3 gap-3">
{(['daily', 'weekly', 'monthly'] as const).map(p => (
<div key={p} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center gap-2">
<PeriodIcon period={p} />
<span className="font-medium capitalize">{p}</span>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => generateReport(p)}
disabled={!!generating}
className="flex-1"
>
{generating === p ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Send className="h-4 w-4 mr-1" />}
Generate & Send
</Button>
<Button
size="sm"
variant="outline"
onClick={() => loadPreview(p)}
disabled={previewLoading}
>
<BarChart3 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
{/* Preview */}
{previewPeriod && previewData && (
<div className="border rounded-lg p-4 bg-muted/30 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold capitalize">{previewPeriod} Preview {previewData.period?.label}</h3>
<Button size="sm" variant="ghost" onClick={() => { setPreviewPeriod(null); setPreviewData(null); }}>
<XCircle className="h-4 w-4" />
</Button>
</div>
<div className="grid grid-cols-4 gap-3 text-center">
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.total_created ?? 0}</div>
<div className="text-xs text-muted-foreground">Created</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.total_resolved ?? 0}</div>
<div className="text-xs text-muted-foreground">Resolved</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{previewData.overview?.avg_resolution_hours ?? '—'}h</div>
<div className="text-xs text-muted-foreground">Avg Resolve</div>
</div>
<div className="bg-background rounded p-2">
<div className="text-2xl font-bold">{(previewData.overview?.total_hours_worked ?? 0).toFixed(1)}h</div>
<div className="text-xs text-muted-foreground">Hours Worked</div>
</div>
</div>
{previewData.noise_candidates?.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-1">🔁 Noise Candidates ({previewData.noise_candidates.length})</h4>
<div className="text-xs space-y-1 max-h-40 overflow-y-auto">
{previewData.noise_candidates.slice(0, 10).map((n: any, i: number) => (
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
<span className="truncate">{n.title}</span>
<span className="text-muted-foreground shrink-0 ml-2">{n.count}× · {n.source_label}</span>
</div>
))}
</div>
</div>
)}
{previewData.top_clients?.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-1">🏢 Top Clients</h4>
<div className="text-xs space-y-1">
{previewData.top_clients.slice(0, 5).map((c: any, i: number) => (
<div key={i} className="flex justify-between bg-background rounded px-2 py-1">
<span>{c.company_name}</span>
<span className="text-muted-foreground">{c.ticket_count} tickets · {c.hours_worked.toFixed(1)}h</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Analysis Sections Config */}
{config && (
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg">Analysis Sections</h2>
<div className="grid grid-cols-2 gap-3">
{([
{ key: 'include_noise_analysis', label: '🔁 Noise & Automation' },
{ key: 'include_sla_analysis', label: '⏱️ SLA & Response Times' },
{ key: 'include_resource_analysis', label: '👥 Team Workload' },
{ key: 'include_client_analysis', label: '🏢 Client Spotlight' },
{ key: 'include_recommendations', label: '💡 Recommendations' },
] as const).map(({ key, label }) => (
<label key={key} className="flex items-center gap-2 cursor-pointer p-2 rounded hover:bg-muted/50">
<input
type="checkbox"
checked={(config as any)[key]}
onChange={() => updateConfig({ [key]: !(config as any)[key] })}
className="rounded"
/>
<span className="text-sm">{label}</span>
</label>
))}
</div>
<div className="flex items-center gap-4 text-sm">
<label className="flex items-center gap-2">
Provider:
<select
value={config.llm_provider}
onChange={e => updateConfig({ llm_provider: e.target.value })}
className="border rounded px-2 py-1 bg-background"
>
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
</select>
</label>
<label className="flex items-center gap-2">
Model:
<input
value={config.llm_model}
onChange={e => updateConfig({ llm_model: e.target.value })}
className="border rounded px-2 py-1 bg-background w-56"
/>
</label>
</div>
</div>
)}
{/* Notification Channels */}
<div className="border rounded-lg p-5 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold text-lg">Delivery Channels</h2>
<p className="text-xs text-muted-foreground mt-0.5">Select which notification channels receive these reports. Manage channels in <a href="/admin/workflow/channels" className="underline">Notification Channels</a>.</p>
</div>
<a href="/admin/workflow/channels" className="text-xs text-muted-foreground flex items-center gap-1 hover:text-foreground">
<ExternalLink className="h-3 w-3" /> Manage Channels
</a>
</div>
{channels.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">
No notification channels configured yet.{' '}
<a href="/admin/workflow/channels" className="underline">Add one here.</a>
</p>
) : (
<div className="space-y-2">
{channels.map(ch => {
const selected = config?.channel_ids?.includes(ch.id) ?? false;
return (
<label
key={ch.id}
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-colors ${
selected ? 'border-primary bg-primary/5' : 'hover:bg-muted/30'
} ${!ch.is_active ? 'opacity-50' : ''}`}
>
<input
type="checkbox"
checked={selected}
onChange={() => toggleChannelId(ch.id)}
className="rounded"
disabled={!ch.is_active}
/>
<ChannelIcon type={ch.channel_type} />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{ch.name}</div>
<div className="text-xs text-muted-foreground capitalize">{ch.channel_type}{!ch.is_active ? ' · Inactive' : ''}</div>
</div>
{selected && (
<span className="text-xs text-primary font-medium">Selected</span>
)}
</label>
);
})}
</div>
)}
{config && (config.channel_ids?.length ?? 0) === 0 && channels.length > 0 && (
<p className="text-xs text-amber-500"> No channels selected reports will be generated but not delivered.</p>
)}
</div>
{/* History */}
<div className="border rounded-lg p-5 space-y-4">
<h2 className="font-semibold text-lg">Report History</h2>
{history.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No reports generated yet. Generate your first report above.</p>
) : (
<div className="space-y-2">
{history.map(report => {
const isExpanded = expandedReport === report.id;
const ov = report.stats?.overview;
return (
<div key={report.id} className="border rounded-lg overflow-hidden">
<button
className="w-full flex items-center justify-between p-3 hover:bg-muted/30 text-left"
onClick={() => setExpandedReport(isExpanded ? null : report.id)}
>
<div className="flex items-center gap-3">
<PeriodIcon period={report.period_type} />
<div>
<span className="font-medium text-sm capitalize">{report.period_type}</span>
<span className="text-xs text-muted-foreground ml-2">
{new Date(report.generated_at).toLocaleString()}
</span>
</div>
</div>
<div className="flex items-center gap-4 text-xs">
{ov && (
<span className="text-muted-foreground">
{ov.total_created} created · {ov.total_resolved} resolved
</span>
)}
{report.tokens_used && (
<span className="text-muted-foreground">{report.tokens_used} tokens</span>
)}
{report.processing_time_ms && (
<span className="text-muted-foreground">{(report.processing_time_ms / 1000).toFixed(1)}s</span>
)}
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</div>
</button>
{isExpanded && report.llm_analysis && (
<div className="px-4 pb-4 border-t">
<div className="mt-3 prose prose-sm max-w-none dark:prose-invert text-sm whitespace-pre-wrap">
{report.llm_analysis}
</div>
{report.delivery_status && Object.keys(report.delivery_status).length > 0 && (
<div className="mt-3 border-t pt-2">
<span className="text-xs font-medium text-muted-foreground">Delivery:</span>
{Object.entries(report.delivery_status).map(([whId, st]) => (
<span key={whId} className={`text-xs ml-2 ${(st as any).success ? 'text-green-500' : 'text-red-500'}`}>
#{whId}: {(st as any).success ? 'OK' : (st as any).error || 'Failed'}
</span>
))}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View file

@ -41,6 +41,12 @@ import {
ChevronDown,
ChevronRight,
Network,
ShieldAlert,
Layers,
BookOpen,
Activity,
Clipboard,
ClipboardCheck,
} from 'lucide-react';
import { toast } from 'sonner';
import { HostManager } from '@/components/zabbix/host-manager';
@ -102,7 +108,37 @@ function ActionBadge({ action }: { action: string }) {
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Gap Analysis types
// ─────────────────────────────────────────────────────────────────────────────
interface GapSummary {
total_rmm_sites: string;
total_zabbix_hosts: string;
zabbix_enabled: string;
rmm_sites_no_zabbix: string;
total_itg_circuits: string;
itg_circuits_monitored: string;
itg_circuits_gap: string;
zabbix_last_synced: string | null;
itg_last_synced: string | null;
}
interface RmmGap { rmm_site_uid: string; rmm_site_name: string; company_name: string; company_id: number; number_of_devices: number; number_of_online_devices: number; }
interface ItgGap { itg_asset_id: number; org_name: string; autotask_company_id: number | null; provider: string; link_type: string; static_ips: string[]; location_name: string; location_city: string; upload_mbps: number; download_mbps: number; }
interface MultiCircuit { org_name: string; autotask_company_id: number | null; total_circuits: string; monitored_circuits: string; gap_circuits: string; circuits: Array<{ id: number; provider: string; link_type: string; static_ips: string[]; location_name: string; zabbix_hostid: string | null; is_decommissioned: boolean; }>; }
interface ProblemRow { hostid: string; display_name: string; wan_ip: string; isp_name: string; autotask_company_name: string; autotask_company_id: number; rmm_site_uid: string; last_problem_at: string; last_problem_name: string; open_rmm_alerts_24h: string; latest_rmm_network_alert: string | null; monitor_tickets_24h: string; }
// ─────────────────────────────────────────────────────────────────────────────
// Correlation types
// ─────────────────────────────────────────────────────────────────────────────
interface CorrSummary { days: number; window_mins: number; zabbix_events_total: number; zabbix_with_rmm_match: number; zabbix_without_rmm_match: number; rmm_only_alerts: number; last_event_sync: string | null; }
interface RmmAlertRef { alert_uid: string; site_name: string; alert_class: string; alert_message: string | null; timestamp: string; resolved: boolean; resolved_on: string | null; device_name: string | null; }
interface ZabbixEventRow { eventid: string; name: string; severity: number; clock: string; r_clock: string | null; duration_seconds: number | null; host_name: string; wan_ip: string; isp_name: string | null; autotask_company_id: number; autotask_company_name: string; rmm_site_uid: string | null; rmm_alert_count: string; rmm_alerts: RmmAlertRef[] | null; }
interface RmmOnlyRow { alert_uid: string; site_name: string; alert_class: string; alert_message: string | null; timestamp: string; resolved: boolean; resolved_on: string | null; device_name: string | null; autotask_company_id: number | null; autotask_company_name: string | null; }
type PageTab = 'sync' | 'gaps' | 'correlation';
export default function ZabbixWanPage() {
const [activeTab, setActiveTab] = useState<PageTab>('sync');
const [mode, setMode] = useState<SyncMode>('all');
const [companyId, setCompanyId] = useState<string>('');
const [siteUid, setSiteUid] = useState<string>('');
@ -122,6 +158,117 @@ export default function ZabbixWanPage() {
const abortRef = useRef<AbortController | null>(null);
const tableBottomRef = useRef<HTMLDivElement>(null);
// Gap analysis state
const [gapLoading, setGapLoading] = useState(false);
const [syncing, setSyncing] = useState<'zabbix' | 'itg' | null>(null);
const [gapSummary, setGapSummary] = useState<GapSummary | null>(null);
const [rmmGaps, setRmmGaps] = useState<RmmGap[]>([]);
const [itgGaps, setItgGaps] = useState<ItgGap[]>([]);
const [multiCircuit, setMultiCircuit] = useState<MultiCircuit[]>([]);
const [problems, setProblems] = useState<ProblemRow[]>([]);
const [gapError, setGapError] = useState<string | null>(null);
const [expandedMulti, setExpandedMulti] = useState<Set<string>>(new Set());
const loadGapAnalysis = async () => {
setGapLoading(true); setGapError(null);
try {
const r = await fetch('/api/zabbix/wan-gap-analysis');
const d = await r.json();
if (!r.ok) throw new Error(d.error);
setGapSummary(d.summary);
setRmmGaps(d.rmm_gaps ?? []);
setItgGaps(d.itg_gaps ?? []);
setMultiCircuit(d.multi_circuit ?? []);
setProblems(d.problems ?? []);
} catch (e) { setGapError(String(e)); }
finally { setGapLoading(false); }
};
const syncZabbixHosts = async () => {
setSyncing('zabbix');
try {
const r = await fetch('/api/zabbix/sync-hosts', { method: 'POST' });
const d = await r.json();
if (!r.ok) throw new Error(d.error);
toast.success(`Zabbix hosts synced — ${d.upserted} upserted, ${d.removed} removed`);
await loadGapAnalysis();
} catch (e) { toast.error('Zabbix sync failed: ' + String(e)); }
finally { setSyncing(null); }
};
const syncItgCircuits = async () => {
setSyncing('itg');
try {
const r = await fetch('/api/itglue/sync-wan', { method: 'POST' });
const d = await r.json();
if (!r.ok) throw new Error(d.error);
toast.success(`IT Glue WAN synced — ${d.upserted} circuits, ${d.matched_zabbix} matched to Zabbix`);
await loadGapAnalysis();
} catch (e) { toast.error('IT Glue sync failed: ' + String(e)); }
finally { setSyncing(null); }
};
const fmtSynced = (ts: string | null) => ts ? new Date(ts).toLocaleString() : 'Never';
const fmtTs = (ts: string | null) => ts ? new Date(ts).toLocaleString() : '—';
const fmtDuration = (s: number | null, hasRecovery?: boolean) => {
if (s === null || s === undefined) return hasRecovery ? '< 1m' : 'Open';
if (s === 0) return hasRecovery ? '< 1m' : 'Open';
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.round(s/60)}m`;
return `${Math.floor(s/3600)}h ${Math.round((s%3600)/60)}m`;
};
const severityLabel = (s: number) => ['','Info','Warning','Average','High','Disaster'][s] ?? String(s);
const severityClass = (s: number) => s >= 4 ? 'text-red-600 font-semibold' : s === 3 ? 'text-orange-500' : 'text-yellow-500';
// Correlation state
const [corrDays, setCorrDays] = useState(30);
const [corrWindow, setCorrWindow] = useState(120);
const [corrLoading, setCorrLoading] = useState(false);
const [corrSummary, setCorrSummary] = useState<CorrSummary | null>(null);
const [zabbixEvents, setZabbixEvents] = useState<ZabbixEventRow[]>([]);
const [rmmOnly, setRmmOnly] = useState<RmmOnlyRow[]>([]);
const [corrError, setCorrError] = useState<string | null>(null);
const [expandedEvent, setExpandedEvent] = useState<Set<string>>(new Set());
const [corrFilter, setCorrFilter] = useState<'all' | 'matched' | 'unmatched'>('all');
const [webhookConfig, setWebhookConfig] = useState<{ script: string; parameters: Array<{name:string;value:string}>; webhook_url: string } | null>(null);
const [copied, setCopied] = useState<'script'|'url'|null>(null);
const [webhookCollapsed, setWebhookCollapsed] = useState(true);
const [rmmOnlyCollapsed, setRmmOnlyCollapsed] = useState(true);
const loadWebhookConfig = async () => {
try {
const r = await fetch('/api/zabbix/webhook');
const d = await r.json();
setWebhookConfig(d);
} catch { /* ignore */ }
};
const copyText = async (text: string, key: 'script'|'url') => {
await navigator.clipboard.writeText(text);
setCopied(key);
setTimeout(() => setCopied(null), 2000);
};
const loadCorrelation = async () => {
setCorrLoading(true); setCorrError(null);
try {
const r = await fetch(`/api/zabbix/alert-correlation?days=${corrDays}&windowMins=${corrWindow}`);
const d = await r.json();
if (!r.ok) throw new Error(d.error);
setCorrSummary(d.summary);
setZabbixEvents(d.zabbix_events ?? []);
setRmmOnly(d.rmm_only ?? []);
} catch (e) { setCorrError(String(e)); }
finally { setCorrLoading(false); }
};
const filteredEvents = zabbixEvents.filter(e =>
corrFilter === 'all' ? true :
corrFilter === 'matched' ? Number(e.rmm_alert_count) > 0 :
Number(e.rmm_alert_count) === 0
);
// Manual host creation state
const [manualOpen, setManualOpen] = useState(false);
const [manualIp, setManualIp] = useState('');
@ -289,11 +436,6 @@ export default function ZabbixWanPage() {
<div className="container mx-auto py-8 max-w-6xl space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Link href="/admin/sync">
<Button variant="ghost" size="sm" className="gap-2">
<ArrowLeft className="w-4 h-4" /> Back
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Globe className="w-6 h-6" /> Zabbix WAN Monitor Setup
@ -304,6 +446,29 @@ export default function ZabbixWanPage() {
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b">
{([['sync', Globe, 'WAN Sync'], ['gaps', ShieldAlert, 'Gap Analysis'], ['correlation', Activity, 'Alert Correlation']] as const).map(([tab, Icon, label]) => (
<button
key={tab}
onClick={() => {
setActiveTab(tab as PageTab);
if (tab === 'gaps' && !gapSummary) loadGapAnalysis();
if (tab === 'correlation' && !webhookConfig) loadWebhookConfig();
}}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Icon className="w-4 h-4" /> {label}
</button>
))}
</div>
{/* ── WAN Sync tab ─────────────────────────────────────────────────── */}
{activeTab === 'sync' && (<>
{/* Config card */}
<Card>
<CardHeader className="pb-4">
@ -732,6 +897,531 @@ export default function ZabbixWanPage() {
Configure your options above and click {dryRun ? 'Preview' : 'Run'} to start.
</div>
)}
</>)}
{/* ── Alert Correlation tab ──────────────────────────────────────── */}
{activeTab === 'correlation' && (
<div className="space-y-6">
{/* Webhook setup */}
{webhookConfig && (
<Card>
<CardHeader className="pb-3 cursor-pointer select-none" onClick={() => setWebhookCollapsed(v => !v)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2"><Clipboard className="w-4 h-4" /> Zabbix Webhook Setup</CardTitle>
{webhookCollapsed ? <ChevronRight className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</div>
{webhookCollapsed && <CardDescription>Click to expand setup instructions</CardDescription>}
{!webhookCollapsed && <CardDescription>Configure a Webhook media type in Zabbix Administration Media types, then create an action that sends to all WAN hosts</CardDescription>}
</CardHeader>
{!webhookCollapsed && <CardContent className="space-y-4">
<div className="flex items-center gap-3">
<code className="flex-1 bg-muted rounded px-3 py-2 text-sm font-mono truncate">{webhookConfig.webhook_url}</code>
<Button variant="outline" size="sm" onClick={() => copyText(webhookConfig.webhook_url, 'url')} className="gap-1.5 shrink-0">
{copied === 'url' ? <ClipboardCheck className="w-3.5 h-3.5 text-green-500" /> : <Clipboard className="w-3.5 h-3.5" />}
{copied === 'url' ? 'Copied' : 'Copy URL'}
</Button>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Media type script</p>
<Button variant="outline" size="sm" onClick={() => copyText(webhookConfig.script, 'script')} className="gap-1.5">
{copied === 'script' ? <ClipboardCheck className="w-3.5 h-3.5 text-green-500" /> : <Clipboard className="w-3.5 h-3.5" />}
{copied === 'script' ? 'Copied' : 'Copy Script'}
</Button>
</div>
<pre className="bg-muted rounded p-3 text-xs font-mono overflow-x-auto max-h-40 leading-relaxed">{webhookConfig.script}</pre>
</div>
<div className="space-y-1.5">
<p className="text-sm font-medium">Parameters to add</p>
<div className="border rounded divide-y text-xs">
{webhookConfig.parameters.map(p => (
<div key={p.name} className="flex items-center px-3 py-1.5 gap-4">
<span className="font-mono w-36 shrink-0 text-muted-foreground">{p.name}</span>
<span className="font-mono">{p.value}</span>
</div>
))}
</div>
</div>
</CardContent>}
</Card>
)}
{/* Controls */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2"><Activity className="w-4 h-4" /> Alert Correlation</CardTitle>
<CardDescription>Compare Zabbix WAN events against Datto RMM ping/offline alerts for the same company and time window</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={loadCorrelation} disabled={corrLoading} className="gap-2">
{corrLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />} Refresh
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end gap-6">
<div className="space-y-1.5">
<Label className="text-sm">Look-back period</Label>
<div className="flex items-center gap-2">
<Input type="number" min={1} max={90} value={corrDays} onChange={e => setCorrDays(Math.max(1,Number(e.target.value)))} className="w-20" />
<span className="text-sm text-muted-foreground">days</span>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-sm">Match window</Label>
<div className="flex items-center gap-2">
<Input type="number" min={5} max={480} value={corrWindow} onChange={e => setCorrWindow(Math.max(5,Number(e.target.value)))} className="w-20" />
<span className="text-sm text-muted-foreground">min ±</span>
</div>
</div>
</div>
{corrSummary && (
<>
<p className="text-xs text-muted-foreground">Last event sync: {fmtSynced(corrSummary.last_event_sync)}</p>
<div className="grid grid-cols-4 gap-3">
<div className="border rounded-lg p-3 text-center"><p className="text-2xl font-bold">{corrSummary.zabbix_events_total}</p><p className="text-xs text-muted-foreground mt-0.5">Zabbix events</p></div>
<div className="border rounded-lg p-3 text-center bg-green-500/5">
<p className="text-2xl font-bold text-green-600">{corrSummary.zabbix_with_rmm_match}</p>
<p className="text-xs text-muted-foreground mt-0.5">Zabbix + RMM correlated</p>
</div>
<div className="border rounded-lg p-3 text-center bg-amber-500/5">
<p className="text-2xl font-bold text-amber-600">{corrSummary.zabbix_without_rmm_match}</p>
<p className="text-xs text-muted-foreground mt-0.5">Zabbix only (no RMM match)</p>
</div>
<div className="border rounded-lg p-3 text-center bg-blue-500/5">
<p className="text-2xl font-bold text-blue-600">{corrSummary.rmm_only_alerts}</p>
<p className="text-xs text-muted-foreground mt-0.5">RMM only (no Zabbix event)</p>
</div>
</div>
</>
)}
</CardContent>
</Card>
{corrError && (
<div className="flex items-center gap-2 text-sm text-destructive border border-destructive/30 bg-destructive/5 rounded-md p-3">
<AlertTriangle className="w-4 h-4" /> {corrError}
</div>
)}
{/* Zabbix events table */}
{(filteredEvents.length > 0 || corrSummary) && (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2"><Globe className="w-4 h-4" /> Zabbix WAN Events</CardTitle>
<div className="flex gap-1">
{(['all','matched','unmatched'] as const).map(f => (
<button key={f} onClick={() => setCorrFilter(f)}
className={`px-3 py-1 rounded text-xs font-medium border transition-colors ${
corrFilter === f ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-accent'
}`}>
{f === 'all' ? `All (${zabbixEvents.length})` : f === 'matched' ? `RMM match (${corrSummary?.zabbix_with_rmm_match ?? 0})` : `No RMM (${corrSummary?.zabbix_without_rmm_match ?? 0})`}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="max-h-[600px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead>Host / Client</TableHead>
<TableHead>WAN IP</TableHead>
<TableHead>Problem</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
<TableHead className="text-center">RMM Alerts</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.map(ev => {
const matched = Number(ev.rmm_alert_count) > 0;
const expanded = expandedEvent.has(ev.eventid);
const alerts: RmmAlertRef[] = ev.rmm_alerts ?? [];
return (<>
<TableRow
key={ev.eventid}
className={`cursor-pointer ${matched ? 'hover:bg-green-500/5' : 'hover:bg-amber-500/5 bg-amber-500/[0.03]'}`}
onClick={() => setExpandedEvent(prev => { const n = new Set(prev); expanded ? n.delete(ev.eventid) : n.add(ev.eventid); return n; })}
>
<TableCell>
<p className="font-medium text-sm">{ev.host_name}</p>
<p className="text-xs text-muted-foreground">{ev.autotask_company_name}</p>
</TableCell>
<TableCell className="font-mono text-sm">{ev.wan_ip}</TableCell>
<TableCell className="text-sm max-w-[200px] truncate" title={ev.name}>{ev.name}</TableCell>
<TableCell><span className={`text-sm ${severityClass(ev.severity)}`}>{severityLabel(ev.severity)}</span></TableCell>
<TableCell className="text-sm">{fmtTs(ev.clock)}</TableCell>
<TableCell className="text-sm tabular-nums">{fmtDuration(ev.duration_seconds, !!ev.r_clock)}</TableCell>
<TableCell className="text-center">
{matched
? <Badge variant="default" className="bg-green-600">{ev.rmm_alert_count}</Badge>
: <Badge variant="outline" className="text-amber-600 border-amber-400">0</Badge>}
</TableCell>
</TableRow>
{expanded && alerts.length > 0 && alerts.map(a => (
<TableRow key={a.alert_uid} className="bg-green-500/5">
<TableCell className="pl-8 text-xs text-muted-foreground" colSpan={2}>
{a.device_name && <span className="font-medium text-foreground">{a.device_name}</span>} · {a.site_name}
</TableCell>
<TableCell className="text-xs" colSpan={2}>
<Badge variant="outline" className="text-xs mr-2">{a.alert_class}</Badge>
<span className="text-muted-foreground truncate max-w-[200px]">{a.alert_message ?? '—'}</span>
</TableCell>
<TableCell className="text-xs">{fmtTs(a.timestamp)}</TableCell>
<TableCell className="text-xs">{a.resolved ? 'Resolved' : 'Open'}</TableCell>
<TableCell />
</TableRow>
))}
{expanded && alerts.length === 0 && (
<TableRow className="bg-amber-500/5">
<TableCell colSpan={7} className="pl-8 text-xs text-muted-foreground italic">No RMM alerts found within ±{corrWindow}min for this company</TableCell>
</TableRow>
)}
</>);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* RMM-only alerts */}
{rmmOnly.length > 0 && (
<Card>
<CardHeader className="pb-3 cursor-pointer select-none" onClick={() => setRmmOnlyCollapsed(v => !v)}>
<div className="flex items-center justify-between">
<CardTitle className="text-base flex items-center gap-2">
<Server className="w-4 h-4 text-blue-500" /> RMM-Only Alerts <Badge variant="outline">{rmmOnly.length}</Badge>
</CardTitle>
{rmmOnlyCollapsed ? <ChevronRight className="w-4 h-4 text-muted-foreground" /> : <ChevronDown className="w-4 h-4 text-muted-foreground" />}
</div>
<CardDescription>RMM ping/offline alerts with no matching Zabbix event potential Zabbix coverage gap</CardDescription>
</CardHeader>
{!rmmOnlyCollapsed && <CardContent className="p-0">
<div className="max-h-[400px] overflow-y-auto">
<Table>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<TableHead>Company</TableHead>
<TableHead>Site</TableHead>
<TableHead>Device</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
<TableHead>Time</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rmmOnly.map(a => (
<TableRow key={a.alert_uid}>
<TableCell className="text-sm font-medium">{a.autotask_company_name ?? '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">{a.site_name}</TableCell>
<TableCell className="text-sm">{a.device_name ?? '—'}</TableCell>
<TableCell><Badge variant="outline" className="text-xs">{a.alert_class}</Badge></TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[220px] truncate" title={a.alert_message ?? ''}>{a.alert_message ?? '—'}</TableCell>
<TableCell className="text-sm">{fmtTs(a.timestamp)}</TableCell>
<TableCell><Badge variant={a.resolved ? 'outline' : 'destructive'} className="text-xs">{a.resolved ? 'Resolved' : 'Open'}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>}
</Card>
)}
{!corrLoading && !corrSummary && !corrError && (
<div className="text-center py-16 text-muted-foreground text-sm">
Click <strong>Refresh</strong> to load correlation data.
</div>
)}
</div>
)}
{/* ── Gap Analysis tab ─────────────────────────────────────────────── */}
{activeTab === 'gaps' && (
<div className="space-y-6">
{/* Sync controls */}
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2"><Layers className="w-4 h-4" /> Data Sources</CardTitle>
<CardDescription>Sync Zabbix hosts and IT Glue WAN circuits into local cache, then run gap analysis</CardDescription>
</div>
<Button variant="outline" size="sm" onClick={loadGapAnalysis} disabled={gapLoading} className="gap-2">
{gapLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />} Refresh
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{/* Zabbix */}
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm flex items-center gap-2"><Globe className="w-4 h-4" /> Zabbix Hosts</p>
<p className="text-xs text-muted-foreground mt-0.5">Last synced: {fmtSynced(gapSummary?.zabbix_last_synced ?? null)}</p>
</div>
<Button size="sm" onClick={syncZabbixHosts} disabled={!!syncing} className="gap-2">
{syncing === 'zabbix' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Sync
</Button>
</div>
{gapSummary && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Total hosts</p><p className="font-semibold text-base">{gapSummary.total_zabbix_hosts}</p></div>
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Enabled</p><p className="font-semibold text-base">{gapSummary.zabbix_enabled}</p></div>
</div>
)}
</div>
{/* IT Glue */}
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-sm flex items-center gap-2"><BookOpen className="w-4 h-4" /> IT Glue WAN Circuits</p>
<p className="text-xs text-muted-foreground mt-0.5">Last synced: {fmtSynced(gapSummary?.itg_last_synced ?? null)}</p>
</div>
<Button size="sm" onClick={syncItgCircuits} disabled={!!syncing} className="gap-2">
{syncing === 'itg' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <RefreshCw className="w-3.5 h-3.5" />} Sync
</Button>
</div>
{gapSummary && (
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="bg-muted/40 rounded p-2"><p className="text-muted-foreground">Total circuits</p><p className="font-semibold text-base">{gapSummary.total_itg_circuits}</p></div>
<div className="bg-green-500/10 rounded p-2"><p className="text-muted-foreground">Monitored</p><p className="font-semibold text-base text-green-600">{gapSummary.itg_circuits_monitored}</p></div>
<div className="bg-amber-500/10 rounded p-2"><p className="text-muted-foreground">Gaps</p><p className="font-semibold text-base text-amber-600">{gapSummary.itg_circuits_gap}</p></div>
</div>
)}
</div>
</div>
{gapSummary && (
<div className="mt-4 grid grid-cols-3 gap-4 text-sm border-t pt-4">
<div className="text-center">
<p className="text-2xl font-bold">{gapSummary.total_rmm_sites}</p>
<p className="text-xs text-muted-foreground">RMM sites mapped</p>
</div>
<div className="text-center">
<p className={`text-2xl font-bold ${Number(gapSummary.rmm_sites_no_zabbix) > 0 ? 'text-amber-500' : 'text-green-600'}`}>
{gapSummary.rmm_sites_no_zabbix}
</p>
<p className="text-xs text-muted-foreground">RMM sites without Zabbix host</p>
</div>
<div className="text-center">
<p className={`text-2xl font-bold ${Number(gapSummary.itg_circuits_gap) > 0 ? 'text-amber-500' : 'text-green-600'}`}>
{gapSummary.itg_circuits_gap}
</p>
<p className="text-xs text-muted-foreground">IT Glue circuits unmonitored</p>
</div>
</div>
)}
</CardContent>
</Card>
{gapError && (
<div className="flex items-center gap-2 text-sm text-destructive border border-destructive/30 bg-destructive/5 rounded-md p-3">
<AlertTriangle className="w-4 h-4" /> {gapError}
</div>
)}
{/* Current Zabbix problems + RMM correlation */}
{problems.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Activity className="w-4 h-4 text-red-500" /> Active / Recent Problems <Badge variant="destructive">{problems.length}</Badge></CardTitle>
<CardDescription>Zabbix WAN problems in the last 24h correlated with RMM alerts and monitor tickets</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Host / Client</TableHead>
<TableHead>WAN IP</TableHead>
<TableHead>ISP</TableHead>
<TableHead>Problem</TableHead>
<TableHead className="text-center">RMM Alerts 24h</TableHead>
<TableHead className="text-center">Monitor Tickets 24h</TableHead>
<TableHead>Latest RMM Network Alert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{problems.map(p => (
<TableRow key={p.hostid} className="bg-red-500/5">
<TableCell>
<p className="font-medium text-sm">{p.display_name}</p>
<p className="text-xs text-muted-foreground">{p.autotask_company_name}</p>
</TableCell>
<TableCell className="font-mono text-sm">{p.wan_ip}</TableCell>
<TableCell className="text-sm">{p.isp_name ?? '—'}</TableCell>
<TableCell className="text-sm max-w-[200px] truncate" title={p.last_problem_name}>{p.last_problem_name ?? '—'}</TableCell>
<TableCell className="text-center">
<Badge variant={Number(p.open_rmm_alerts_24h) > 0 ? 'destructive' : 'outline'}>{p.open_rmm_alerts_24h}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={Number(p.monitor_tickets_24h) > 0 ? 'secondary' : 'outline'}>{p.monitor_tickets_24h}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[220px] truncate" title={p.latest_rmm_network_alert ?? ''}>
{p.latest_rmm_network_alert ?? <span className="italic">None</span>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Multi-circuit companies */}
{multiCircuit.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><GitFork className="w-4 h-4" /> Multi-Circuit Companies</CardTitle>
<CardDescription>Companies with more than one WAN circuit in IT Glue check each is covered in Zabbix</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead className="text-center">Circuits</TableHead>
<TableHead className="text-center">Monitored</TableHead>
<TableHead className="text-center">Gaps</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{multiCircuit.map(mc => {
const key = mc.org_name;
const expanded = expandedMulti.has(key);
return (<>
<TableRow
key={key}
className={`cursor-pointer hover:bg-muted/30 ${Number(mc.gap_circuits) > 0 ? 'bg-amber-500/5' : ''}`}
onClick={() => setExpandedMulti(prev => { const n = new Set(prev); expanded ? n.delete(key) : n.add(key); return n; })}
>
<TableCell className="font-medium text-sm">{mc.org_name}</TableCell>
<TableCell className="text-center">{mc.total_circuits}</TableCell>
<TableCell className="text-center"><Badge variant={Number(mc.monitored_circuits) > 0 ? 'default' : 'outline'} className="bg-green-600">{mc.monitored_circuits}</Badge></TableCell>
<TableCell className="text-center">{Number(mc.gap_circuits) > 0 ? <Badge variant="destructive">{mc.gap_circuits}</Badge> : <Badge variant="outline">0</Badge>}</TableCell>
<TableCell>{expanded ? <ChevronDown className="w-4 h-4 text-muted-foreground" /> : <ChevronRight className="w-4 h-4 text-muted-foreground" />}</TableCell>
</TableRow>
{expanded && mc.circuits.map(c => (
<TableRow key={c.id} className="bg-muted/20">
<TableCell colSpan={2} className="pl-8 text-sm">
<span className="font-medium">{c.provider}</span>
{c.location_name && <span className="text-muted-foreground"> {c.location_name}</span>}
{c.is_decommissioned && <Badge variant="outline" className="ml-2 text-xs">Decommissioned</Badge>}
</TableCell>
<TableCell className="text-xs text-muted-foreground">{c.link_type}</TableCell>
<TableCell className="font-mono text-xs">{c.static_ips?.join(', ') || '—'}</TableCell>
<TableCell colSpan={2}>
{c.zabbix_hostid
? <Badge variant="default" className="bg-green-600 gap-1"><CheckCircle2 className="w-3 h-3" /> Monitored</Badge>
: c.is_decommissioned
? <Badge variant="outline" className="gap-1"><MinusCircle className="w-3 h-3" /> Decommissioned</Badge>
: <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" /> No Zabbix Host</Badge>
}
</TableCell>
</TableRow>
))}
</>);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* IT Glue gaps */}
{itgGaps.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><BookOpen className="w-4 h-4 text-amber-500" /> IT Glue Circuits Without Zabbix Monitoring <Badge variant="outline">{itgGaps.length}</Badge></CardTitle>
<CardDescription>Active circuits with documented static IPs in IT Glue that have no matching Zabbix host</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Organization</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Type</TableHead>
<TableHead>Static IPs</TableHead>
<TableHead>Location</TableHead>
<TableHead>Speed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{itgGaps.map(g => (
<TableRow key={g.itg_asset_id}>
<TableCell className="font-medium text-sm">{g.org_name}</TableCell>
<TableCell className="text-sm">{g.provider ?? '—'}</TableCell>
<TableCell className="text-sm">{g.link_type ?? '—'}</TableCell>
<TableCell className="font-mono text-xs">{g.static_ips?.join(', ') || '—'}</TableCell>
<TableCell className="text-sm text-muted-foreground">{[g.location_name, g.location_city].filter(Boolean).join(', ') || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{g.download_mbps ? `${g.download_mbps}` : ''}{g.upload_mbps ? `${g.upload_mbps} Mbps` : ''}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* RMM sites without Zabbix */}
{rmmGaps.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Server className="w-4 h-4 text-amber-500" /> RMM Sites Without Zabbix Host <Badge variant="outline">{rmmGaps.length}</Badge></CardTitle>
<CardDescription>Online RMM sites that have no corresponding Zabbix WAN monitor run WAN Sync to add them</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Site</TableHead>
<TableHead>Company</TableHead>
<TableHead className="text-center">Devices</TableHead>
<TableHead className="text-center">Online</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rmmGaps.map(g => (
<TableRow key={g.rmm_site_uid}>
<TableCell className="font-medium text-sm">{g.rmm_site_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{g.company_name}</TableCell>
<TableCell className="text-center tabular-nums">{g.number_of_devices}</TableCell>
<TableCell className="text-center tabular-nums text-green-600">{g.number_of_online_devices}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{!gapLoading && gapSummary && rmmGaps.length === 0 && itgGaps.length === 0 && problems.length === 0 && (
<div className="text-center py-16 text-muted-foreground text-sm">
<CheckCircle2 className="w-8 h-8 mx-auto mb-3 text-green-500" />
All monitored sites and IT Glue circuits are covered in Zabbix.
</div>
)}
{!gapLoading && !gapSummary && !gapError && (
<div className="text-center py-16 text-muted-foreground text-sm">
Sync Zabbix hosts and IT Glue circuits above to run gap analysis.
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,151 @@
/**
* POST /api/itglue/sync-wan
* Pulls all Internet/WAN flexible assets (type 3794) from the local itg_flexible_assets
* table, parses static IPs, and upserts into itg_wan_circuits.
* Also matches Zabbix WAN hosts by IP to populate zabbix_hostid.
*/
import { NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export const maxDuration = 120;
// Extract public IPs from the raw HTML/text blob IT Glue stores
function parseStaticIps(raw: string | null): string[] {
if (!raw) return [];
// Strip HTML tags
const text = raw.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ');
// Match IPv4 addresses
const matches = text.match(/\b(\d{1,3}\.){3}\d{1,3}\b/g) ?? [];
// Filter out private/RFC1918 ranges, subnet masks, gateways that look like masks
return [...new Set(
matches.filter(ip => {
const parts = ip.split('.').map(Number);
if (parts[0] === 10) return false;
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return false;
if (parts[0] === 192 && parts[1] === 168) return false;
if (parts[0] === 255) return false;
if (parts[0] === 0) return false;
if (ip === 'DHCP') return false;
return true;
})
)];
}
function isDecommissioned(notes: string | null): boolean {
if (!notes) return false;
const lower = notes.toLowerCase();
return lower.includes('decommission') || lower.includes('decomission') || lower.includes('retired') || lower.includes('removed');
}
export async function POST() {
try {
// Pull all IT Glue WAN flexible assets joined to org data
const assets = await postgresClient.query<{
id: number;
organization_id: number;
org_name: string;
psa_id: string | null;
provider: string | null;
link_type: string | null;
static_ips_raw: string | null;
upload_mbps: string | null;
download_mbps: string | null;
location_name: string | null;
location_city: string | null;
notes: string | null;
}>(`
SELECT
fa.id,
fa.organization_id,
io.name AS org_name,
io.psa_id,
fa.traits->>'provider' AS provider,
fa.traits->>'link-type' AS link_type,
fa.traits->>'static-ip-address-es' AS static_ips_raw,
fa.traits->>'upload-speed-mbps' AS upload_mbps,
fa.traits->>'download-speed-mbps' AS download_mbps,
fa.traits->'location-s'->'values'->0->>'name' AS location_name,
fa.traits->'location-s'->'values'->0->>'city' AS location_city,
fa.traits->>'notes' AS notes
FROM itg_flexible_assets fa
JOIN itg_organizations io ON io.id = fa.organization_id
WHERE fa.flexible_asset_type_id = 3794
`);
// Build IP → hostid map from zabbix_wan_hosts for matching
const zabbixRows = await postgresClient.query<{ hostid: string; wan_ip: string }>(
'SELECT hostid, wan_ip FROM zabbix_wan_hosts WHERE wan_ip IS NOT NULL'
);
const zabbixByIp = new Map<string, string>();
for (const r of zabbixRows.rows) {
zabbixByIp.set(r.wan_ip, r.hostid);
}
let upserted = 0;
for (const a of assets.rows) {
const staticIps = parseStaticIps(a.static_ips_raw);
const decommissioned = isDecommissioned(a.notes);
const autotaskCompanyId = a.psa_id ? Number(a.psa_id) : null;
// Try to match a Zabbix host by any of the circuit's static IPs
let zabbixHostid: string | null = null;
for (const ip of staticIps) {
if (zabbixByIp.has(ip)) { zabbixHostid = zabbixByIp.get(ip)!; break; }
}
await postgresClient.query(
`INSERT INTO itg_wan_circuits (
id, organization_id, org_name, autotask_company_id,
provider, link_type, static_ips, raw_ip_text,
upload_mbps, download_mbps, location_name, location_city,
notes, is_decommissioned, zabbix_hostid,
last_synced_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),NOW())
ON CONFLICT (id) DO UPDATE SET
org_name = EXCLUDED.org_name,
autotask_company_id = EXCLUDED.autotask_company_id,
provider = EXCLUDED.provider,
link_type = EXCLUDED.link_type,
static_ips = EXCLUDED.static_ips,
raw_ip_text = EXCLUDED.raw_ip_text,
upload_mbps = EXCLUDED.upload_mbps,
download_mbps = EXCLUDED.download_mbps,
location_name = EXCLUDED.location_name,
location_city = EXCLUDED.location_city,
notes = EXCLUDED.notes,
is_decommissioned = EXCLUDED.is_decommissioned,
zabbix_hostid = EXCLUDED.zabbix_hostid,
last_synced_at = NOW(),
updated_at = NOW()`,
[
a.id, a.organization_id, a.org_name, autotaskCompanyId,
a.provider, a.link_type, staticIps, a.static_ips_raw,
a.upload_mbps ? Number(a.upload_mbps) : null,
a.download_mbps ? Number(a.download_mbps) : null,
a.location_name, a.location_city,
a.notes, decommissioned, zabbixHostid,
]
);
upserted++;
}
// Summary stats
const stats = await postgresClient.query<{
total: string; with_ip: string; matched_zabbix: string; decommissioned: string; no_itg_org_link: string;
}>(`
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE array_length(static_ips,1) > 0) AS with_ip,
COUNT(*) FILTER (WHERE zabbix_hostid IS NOT NULL) AS matched_zabbix,
COUNT(*) FILTER (WHERE is_decommissioned) AS decommissioned,
COUNT(*) FILTER (WHERE autotask_company_id IS NULL) AS no_itg_org_link
FROM itg_wan_circuits
`);
return NextResponse.json({ upserted, ...stats.rows[0] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -62,6 +62,7 @@ export async function GET(request: NextRequest) {
LEFT JOIN companies c ON t.company_id = c.id
LEFT JOIN statuses s ON t.status = s.value
WHERE t.completed_date IS NULL
AND t.is_deleted = false
AND (t.source IS NULL OR t.source != 8)
${excludeCompanyFilter}
ORDER BY t.last_activity_date DESC NULLS LAST

View file

@ -52,6 +52,7 @@ export async function GET(request: NextRequest) {
`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}`
@ -63,6 +64,7 @@ export async function GET(request: NextRequest) {
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}
@ -75,6 +77,7 @@ export async function GET(request: NextRequest) {
`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}`
@ -86,6 +89,7 @@ export async function GET(request: NextRequest) {
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}
@ -98,6 +102,7 @@ export async function GET(request: NextRequest) {
`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}`
@ -109,6 +114,7 @@ export async function GET(request: NextRequest) {
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}
@ -121,6 +127,7 @@ export async function GET(request: NextRequest) {
`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}`
@ -132,6 +139,7 @@ export async function GET(request: NextRequest) {
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}
@ -144,6 +152,7 @@ export async function GET(request: NextRequest) {
`SELECT COUNT(*) as count
FROM tickets
WHERE completed_date IS NULL
AND is_deleted = false
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
@ -153,6 +162,7 @@ export async function GET(request: NextRequest) {
`SELECT COUNT(*) as count
FROM tickets
WHERE DATE(completed_date) = CURRENT_DATE
AND is_deleted = false
AND (source IS NULL OR source != 8)
${excludeCompanyFilter}`
);
@ -163,6 +173,7 @@ export async function GET(request: NextRequest) {
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
@ -175,6 +186,7 @@ export async function GET(request: NextRequest) {
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}`
);
@ -185,6 +197,7 @@ export async function GET(request: NextRequest) {
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}`
);
@ -195,6 +208,7 @@ export async function GET(request: NextRequest) {
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}`
);
@ -221,6 +235,7 @@ export async function GET(request: NextRequest) {
) 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}
@ -284,6 +299,7 @@ export async function GET(request: NextRequest) {
`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)
@ -302,6 +318,7 @@ export async function GET(request: NextRequest) {
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)
@ -318,6 +335,7 @@ export async function GET(request: NextRequest) {
`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)
@ -331,6 +349,7 @@ export async function GET(request: NextRequest) {
`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}`
@ -341,6 +360,7 @@ export async function GET(request: NextRequest) {
`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)`
);
@ -350,6 +370,7 @@ export async function GET(request: NextRequest) {
`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)`
);
@ -359,6 +380,7 @@ export async function GET(request: NextRequest) {
`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)`
);

48
app/api/qbo/auth/route.ts Normal file
View file

@ -0,0 +1,48 @@
/**
* QBO OAuth2 Authorization
* GET /api/qbo/auth redirect to Intuit authorization page
* GET /api/qbo/auth/callback exchange code for tokens
*/
import { NextRequest, NextResponse } from 'next/server';
import { QboClient } from '@/lib/services/qbo-client';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const code = searchParams.get('code');
const realmId = searchParams.get('realmId');
const error = searchParams.get('error');
const redirectUri = `${process.env.NEXTAUTH_URL}/api/qbo/auth`;
// ── Callback from Intuit ──────────────────────────────────────────────────
const baseUrl = process.env.NEXTAUTH_URL || 'https://pulse.wulfconsulting.cloud';
if (code && realmId) {
try {
const client = new QboClient();
await client.exchangeCodeForToken(code, redirectUri);
console.log(`[QBO Auth] Tokens saved for realm ${realmId}`);
return NextResponse.redirect(`${baseUrl}/admin/qbo?connected=true`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[QBO Auth] Token exchange failed:', msg);
return NextResponse.redirect(`${baseUrl}/admin/qbo?error=${encodeURIComponent(msg)}`);
}
}
if (error) {
return NextResponse.redirect(`${baseUrl}/admin/qbo?error=${encodeURIComponent(error)}`);
}
// ── Initiate authorization ────────────────────────────────────────────────
try {
const client = new QboClient();
const state = Math.random().toString(36).slice(2);
const authUrl = client.getAuthorizationUrl(redirectUri, state);
return NextResponse.redirect(authUrl);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,21 @@
/**
* QBO Disconnect
* GET /api/qbo/disconnect revokes QBO token and removes from DB
*/
import { NextResponse } from 'next/server';
import postgresClient from '@/lib/services/postgres-client';
export async function GET() {
const realmId = process.env.QBO_REALM_ID || '';
const baseUrl = process.env.NEXTAUTH_URL || 'https://pulse.wulfconsulting.cloud';
try {
await postgresClient.query(`DELETE FROM qbo_tokens WHERE realm_id = $1`, [realmId]);
console.log(`[QBO] Disconnected realm ${realmId}`);
return NextResponse.redirect(`${baseUrl}/admin/qbo?disconnected=true`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.redirect(`${baseUrl}/admin/qbo?error=${encodeURIComponent(msg)}`);
}
}

83
app/api/qbo/sync/route.ts Normal file
View file

@ -0,0 +1,83 @@
/**
* QBO Sync API
* POST /api/qbo/sync trigger a full or incremental sync
* GET /api/qbo/sync get last sync status and record counts
*/
import { NextRequest, NextResponse } from 'next/server';
import { QboSyncService } from '@/lib/services/qbo-sync-service';
import { QboClient } from '@/lib/services/qbo-client';
import postgresClient from '@/lib/services/postgres-client';
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const syncType: 'full' | 'incremental' = body.syncType === 'incremental' ? 'incremental' : 'full';
const triggeredBy = body.triggeredBy || 'api';
const client = new QboClient();
const service = new QboSyncService(client);
if (service.isSyncInProgress()) {
return NextResponse.json({ error: 'QBO sync already in progress' }, { status: 409 });
}
// Run async — return immediately
service[syncType === 'full' ? 'fullSync' : 'incrementalSync'](triggeredBy).catch((err) => {
console.error('[QBO Sync API] Sync failed:', err);
});
return NextResponse.json({
message: `QBO ${syncType} sync started`,
triggeredBy,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET() {
try {
const [invoices, payments, deposits, transactions, reports] = await Promise.all([
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_invoices`),
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_payments`),
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_deposits`),
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_transactions`),
postgresClient.query(`SELECT COUNT(*) as count, MAX(synced_at) as last_sync FROM qbo_reports`),
]);
// Check token status
let tokenStatus: 'valid' | 'expired' | 'missing' = 'missing';
try {
const client = new QboClient();
const token = await client.loadToken();
if (token) {
tokenStatus = new Date() < new Date(token.access_token_expires_at) ? 'valid' : 'expired';
}
} catch {
tokenStatus = 'missing';
}
return NextResponse.json({
tokenStatus,
counts: {
invoices: parseInt(invoices.rows[0]?.count || '0'),
payments: parseInt(payments.rows[0]?.count || '0'),
deposits: parseInt(deposits.rows[0]?.count || '0'),
transactions: parseInt(transactions.rows[0]?.count || '0'),
reports: parseInt(reports.rows[0]?.count || '0'),
},
lastSync: {
invoices: invoices.rows[0]?.last_sync ?? null,
payments: payments.rows[0]?.last_sync ?? null,
deposits: deposits.rows[0]?.last_sync ?? null,
transactions: transactions.rows[0]?.last_sync ?? null,
reports: reports.rows[0]?.last_sync ?? null,
},
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,34 @@
/**
* Ticket Digest Config API
* GET /api/reports/ticket-digest/config Get config + available channels
* PUT /api/reports/ticket-digest/config Update config
*/
import { NextRequest, NextResponse } from 'next/server';
import { getTicketDigestService } from '@/lib/services/ticket-digest-service';
export async function GET() {
try {
const service = getTicketDigestService();
const [config, channels] = await Promise.all([
service.getConfig(),
service.getAvailableChannels(),
]);
return NextResponse.json({ config, channels });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json();
const service = getTicketDigestService();
const config = await service.updateConfig(body);
return NextResponse.json({ config });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,70 @@
/**
* Ticket Digest Report API
* POST /api/reports/ticket-digest Generate and deliver a digest report
* Body: { period: 'daily' | 'weekly' | 'monthly', webhookIds?: number[] }
* GET /api/reports/ticket-digest Get report history
* GET /api/reports/ticket-digest?preview=daily Aggregate data only (no LLM, no delivery)
*/
import { NextRequest, NextResponse } from 'next/server';
import { getTicketDigestService, DigestPeriod } from '@/lib/services/ticket-digest-service';
const VALID_PERIODS: DigestPeriod[] = ['daily', 'weekly', 'monthly'];
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const period = body.period as DigestPeriod;
if (!period || !VALID_PERIODS.includes(period)) {
return NextResponse.json(
{ error: `Invalid period. Must be one of: ${VALID_PERIODS.join(', ')}` },
{ status: 400 }
);
}
const service = getTicketDigestService();
const result = await service.run(period, body.channelIds);
return NextResponse.json({
success: true,
period,
stats: result.stats.overview,
noiseCount: result.stats.noise_candidates.length,
analysisLength: result.analysis.length,
deliveryResults: result.deliveryResults,
processingTimeMs: result.processingTimeMs,
});
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[TICKET-DIGEST API] Error:', msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const preview = searchParams.get('preview') as DigestPeriod | null;
const service = getTicketDigestService();
if (preview && VALID_PERIODS.includes(preview)) {
const stats = await service.aggregate(preview);
return NextResponse.json({ stats });
}
// Return history + config + available notification channels
const [history, config, channels] = await Promise.all([
service.getHistory(20),
service.getConfig(),
service.getAvailableChannels(),
]);
return NextResponse.json({ history, config, channels });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
console.error('[TICKET-DIGEST API] Error:', msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -8,6 +8,17 @@ import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
import '@/lib/services/pipeline-steps';
import { pipelineEngine } from '@/lib/services/pipeline-engine';
import { getDattoRMMClient } from '@/lib/services/datto-rmm-factory';
async function getDattoPingTarget(alertUid: string): Promise<string | null> {
try {
const client = getDattoRMMClient();
const target = await client.getPingAlertTarget(alertUid);
return target;
} catch {
return null;
}
}
/**
* POST /api/webhooks/datto-rmm
@ -70,6 +81,83 @@ export async function POST(request: NextRequest) {
]
);
// Upsert into datto_rmm_alerts if payload looks like a real alert
if (payload && typeof payload === 'object' && payload.alert_uid && !payload.alert_uid.startsWith('[')) {
const pingTarget = payload.alert_type === 'PING'
? await getDattoPingTarget(payload.alert_uid)
: null;
const isResolved = String(payload.triggered).toLowerCase() === 'false';
const str = (v: unknown) => (v && String(v).trim() !== '' ? String(v) : null);
await postgresClient.query(
`INSERT INTO datto_rmm_alerts (
alert_uid, device_uid, device_name, device_hostname, device_ip, device_os,
device_description, device_id, site_uid, site_name, site_id, platform,
priority, alert_category, alert_type, alert_message_en, last_user,
triggered, resolved, resolved_on,
device_udf1, device_udf2, device_udf3, device_udf4, device_udf5,
device_udf6, device_udf7, device_udf8, device_udf9, device_udf10,
device_udf11, device_udf12, device_udf13, device_udf14, device_udf15,
device_udf16, device_udf17, device_udf18, device_udf19, device_udf20,
device_udf21, device_udf22, device_udf23, device_udf24, device_udf25,
device_udf26, device_udf27, device_udf28, device_udf29,
ping_target,
timestamp, synced_at
) VALUES (
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,
$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34,$35,$36,$37,$38,$39,$40,
$41,$42,$43,$44,$45,$46,$47,$48,$49,
$50,
$51,$52
)
ON CONFLICT (alert_uid) DO UPDATE SET
resolved = EXCLUDED.resolved,
resolved_on = CASE WHEN EXCLUDED.resolved AND datto_rmm_alerts.resolved_on IS NULL
THEN NOW() ELSE datto_rmm_alerts.resolved_on END,
triggered = EXCLUDED.triggered,
alert_message_en = COALESCE(EXCLUDED.alert_message_en, datto_rmm_alerts.alert_message_en),
ping_target = COALESCE(EXCLUDED.ping_target, datto_rmm_alerts.ping_target),
synced_at = NOW()`,
[
payload.alert_uid,
str(payload.device_uid),
str(payload.device_hostname),
str(payload.device_hostname),
str(payload.device_ip),
str(payload.device_os),
str(payload.device_description),
str(payload.device_id),
str(payload.site_uid),
str(payload.site_name),
str(payload.site_id),
str(payload.platform),
str(payload.alert_priority),
str(payload.alert_category),
str(payload.alert_type),
str(payload.alert_message_en),
str(payload.last_user),
String(payload.triggered),
isResolved,
isResolved ? receivedAt : null,
str(payload.device_udf1), str(payload.device_udf2), str(payload.device_udf3),
str(payload.device_udf4), str(payload.device_udf5), str(payload.device_udf6),
str(payload.device_udf7), str(payload.device_udf8), str(payload.device_udf9),
str(payload.device_udf10), str(payload.device_udf11), str(payload.device_udf12),
str(payload.device_udf13), str(payload.device_udf14), str(payload.device_udf15),
str(payload.device_udf16), str(payload.device_udf17), str(payload.device_udf18),
str(payload.device_udf19), str(payload.device_udf20),
str(payload.device_udf21), str(payload.device_udf22), str(payload.device_udf23),
str(payload.device_udf24), str(payload.device_udf25), str(payload.device_udf26),
str(payload.device_udf27), str(payload.device_udf28), str(payload.device_udf29),
pingTarget,
receivedAt,
receivedAt,
]
);
console.log(`[DATTO-RMM-WEBHOOK] Upserted alert ${payload.alert_uid} — resolved=${isResolved}`);
}
// Fire matching pipelines (fire-and-forget)
if (payload && typeof payload === 'object') {
pipelineEngine.processTrigger('datto_rmm', payload).catch(err =>

View file

@ -0,0 +1,154 @@
/**
* GET /api/zabbix/alert-correlation
* Cross-references Zabbix WAN problem events against Datto RMM ping/offline alerts
* for the same company within a configurable time window.
*
* Query params:
* days look-back window in days (default 30)
* windowMins match window ± minutes around Zabbix event (default 120)
* companyId optional: filter to one Autotask company
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const days = Number(searchParams.get('days') ?? 30);
const windowMins = Number(searchParams.get('windowMins') ?? 120);
const companyId = searchParams.get('companyId');
try {
const since = new Date(Date.now() - days * 86400 * 1000);
// ── 1. Zabbix events with matched RMM alert count ────────────────────────
const zabbixRows = await postgresClient.query<{
eventid: string;
name: string;
severity: number;
clock: string;
r_clock: string | null;
duration_seconds: number | null;
host_name: string;
wan_ip: string;
isp_name: string | null;
autotask_company_id: number;
autotask_company_name: string;
rmm_site_uid: string | null;
rmm_alert_count: string;
rmm_alerts: string; // JSON array
}>(`
SELECT
ze.eventid,
ze.name,
ze.severity,
ze.clock,
ze.r_clock,
ze.duration_seconds,
ze.host_name,
ze.wan_ip,
ze.isp_name,
ze.autotask_company_id,
ze.autotask_company_name,
ze.rmm_site_uid,
COUNT(ra.alert_uid)::text AS rmm_alert_count,
json_agg(json_build_object(
'alert_uid', ra.alert_uid,
'site_name', ra.site_name,
'alert_class', ra.alert_context->>'@class',
'alert_message', ra.alert_message_en,
'timestamp', ra.timestamp,
'resolved', ra.resolved,
'resolved_on', ra.resolved_on,
'device_name', ra.device_name
) ORDER BY ra.timestamp)
FILTER (WHERE ra.alert_uid IS NOT NULL) AS rmm_alerts
FROM zabbix_events ze
LEFT JOIN datto_rmm_alerts ra
ON ra.timestamp BETWEEN ze.clock - ($1 * INTERVAL '1 minute')
AND ze.clock + ($1 * INTERVAL '1 minute')
AND EXISTS (
SELECT 1 FROM datto_rmm_sites ds
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = ds.uid
WHERE ds.uid = ra.site_uid
AND rsm.company_id = ze.autotask_company_id
)
WHERE ze.clock >= $2
${companyId ? 'AND ze.autotask_company_id = $3' : ''}
GROUP BY
ze.eventid, ze.name, ze.severity, ze.clock, ze.r_clock,
ze.duration_seconds, ze.host_name, ze.wan_ip, ze.isp_name,
ze.autotask_company_id, ze.autotask_company_name, ze.rmm_site_uid
ORDER BY ze.clock DESC
`, companyId
? [windowMins, since, Number(companyId)]
: [windowMins, since]);
// ── 2. RMM ping/offline alerts with NO matching Zabbix event ────────────
const rmmOnlyRows = await postgresClient.query<{
alert_uid: string;
site_name: string;
alert_class: string;
alert_message: string | null;
timestamp: string;
resolved: boolean;
resolved_on: string | null;
device_name: string | null;
autotask_company_id: number | null;
autotask_company_name: string | null;
}>(`
SELECT
ra.alert_uid,
ra.site_name,
ra.alert_context->>'@class' AS alert_class,
ra.alert_message_en AS alert_message,
ra.timestamp,
ra.resolved,
ra.resolved_on,
ra.device_name,
rsm.company_id AS autotask_company_id,
c.company_name AS autotask_company_name
FROM datto_rmm_alerts ra
JOIN datto_rmm_sites ds ON ds.uid = ra.site_uid
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = ds.uid
JOIN companies c ON c.id = rsm.company_id
WHERE ra.timestamp >= $1
AND ra.alert_context->>'@class' IN ('ping_ctx', 'device_offline_ctx', 'network_ctx')
AND NOT EXISTS (
SELECT 1 FROM zabbix_events ze
WHERE ze.autotask_company_id = rsm.company_id
AND ze.clock BETWEEN ra.timestamp - ($2 * INTERVAL '1 minute')
AND ra.timestamp + ($2 * INTERVAL '1 minute')
)
${companyId ? 'AND rsm.company_id = $3' : ''}
ORDER BY ra.timestamp DESC
LIMIT 500
`, companyId
? [since, windowMins, Number(companyId)]
: [since, windowMins]);
// ── 3. Summary stats ─────────────────────────────────────────────────────
const zabbixWithRmm = zabbixRows.rows.filter(r => Number(r.rmm_alert_count) > 0).length;
const zabbixWithoutRmm = zabbixRows.rows.filter(r => Number(r.rmm_alert_count) === 0).length;
const lastEventSync = await postgresClient.query<{ last_sync: string | null }>(
'SELECT MAX(last_synced_at) AS last_sync FROM zabbix_events'
);
return NextResponse.json({
summary: {
days,
window_mins: windowMins,
zabbix_events_total: zabbixRows.rows.length,
zabbix_with_rmm_match: zabbixWithRmm,
zabbix_without_rmm_match: zabbixWithoutRmm,
rmm_only_alerts: rmmOnlyRows.rows.length,
last_event_sync: lastEventSync.rows[0]?.last_sync ?? null,
},
zabbix_events: zabbixRows.rows,
rmm_only: rmmOnlyRows.rows,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,122 @@
/**
* POST /api/zabbix/sync-events
* Polls Zabbix for WAN problem events (past N days) and caches them in zabbix_events.
* Joins against local zabbix_wan_hosts to enrich with company/site context.
* Body: { days?: number } defaults to 30
*/
import { NextRequest, NextResponse } from 'next/server';
import { ZabbixClient } from '@/lib/services/zabbix-client';
import { postgresClient } from '@/lib/services/postgres-client';
export const maxDuration = 300;
export async function POST(request: NextRequest) {
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
return NextResponse.json({ error: 'Zabbix not configured' }, { status: 500 });
}
const body = await request.json().catch(() => ({}));
const days: number = Number(body.days ?? 30);
const zabbix = new ZabbixClient({
apiUrl: process.env.ZABBIX_API_URL,
apiToken: process.env.ZABBIX_API_TOKEN,
});
try {
// Load local WAN host cache for enrichment (hostid → context)
const hostRows = await postgresClient.query<{
hostid: string;
display_name: string;
wan_ip: string;
autotask_company_id: number | null;
autotask_company_name: string | null;
rmm_site_uid: string | null;
isp_name: string | null;
}>('SELECT hostid, display_name, wan_ip, autotask_company_id, autotask_company_name, rmm_site_uid, isp_name FROM zabbix_wan_hosts');
const hostMap = new Map(hostRows.rows.map(r => [r.hostid, r]));
if (hostMap.size === 0) {
return NextResponse.json({ error: 'No Zabbix hosts cached — run Sync Hosts first' }, { status: 400 });
}
const from = new Date(Date.now() - days * 86400 * 1000);
const to = new Date();
// Pull WAN-host-only events by scoping to our known hostids
const hostIds = [...hostMap.keys()];
const events = await zabbix.getEvents({ hostIds, from, to, limit: 10000 });
let upserted = 0;
let noHost = 0;
for (const ev of events) {
const hostid = ev.hosts?.[0]?.hostid ?? null;
const host = hostid ? hostMap.get(hostid) ?? null : null;
if (!host) { noHost++; continue; }
const clockTs = new Date(Number(ev.clock) * 1000);
const rClockTs = ev.r_clock && ev.r_clock !== '0'
? new Date(Number(ev.r_clock) * 1000)
: null;
const duration = rClockTs
? Math.round((rClockTs.getTime() - clockTs.getTime()) / 1000)
: null;
await postgresClient.query(
`INSERT INTO zabbix_events (
eventid, objectid, name, severity, clock,
r_eventid, r_clock, duration_seconds,
hostid, host_name, wan_ip,
autotask_company_id, autotask_company_name, rmm_site_uid, isp_name,
last_synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW())
ON CONFLICT (eventid) DO UPDATE SET
r_eventid = EXCLUDED.r_eventid,
r_clock = EXCLUDED.r_clock,
duration_seconds = EXCLUDED.duration_seconds,
host_name = EXCLUDED.host_name,
wan_ip = EXCLUDED.wan_ip,
autotask_company_id = EXCLUDED.autotask_company_id,
autotask_company_name = EXCLUDED.autotask_company_name,
rmm_site_uid = EXCLUDED.rmm_site_uid,
isp_name = EXCLUDED.isp_name,
last_synced_at = NOW()`,
[
ev.eventid,
ev.objectid,
ev.name,
Number(ev.severity),
clockTs,
ev.r_eventid && ev.r_eventid !== '0' ? ev.r_eventid : null,
rClockTs,
duration,
hostid,
host.display_name,
host.wan_ip,
host.autotask_company_id,
host.autotask_company_name,
host.rmm_site_uid,
host.isp_name,
]
);
upserted++;
}
const stats = await postgresClient.query<{ total: string; open: string; oldest: string; newest: string }>(`
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE r_eventid IS NULL) AS open,
MIN(clock) AS oldest,
MAX(clock) AS newest
FROM zabbix_events
`);
return NextResponse.json({ upserted, no_host: noHost, days, ...stats.rows[0] });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,122 @@
/**
* POST /api/zabbix/sync-hosts
* Pulls all hosts from the live Zabbix API and caches them in zabbix_wan_hosts.
* Also fetches current open problems and stamps last_problem_at on affected hosts.
*/
import { NextResponse } from 'next/server';
import { ZabbixClient } from '@/lib/services/zabbix-client';
import { postgresClient } from '@/lib/services/postgres-client';
export const maxDuration = 120;
export async function POST() {
if (!process.env.ZABBIX_API_URL || !process.env.ZABBIX_API_TOKEN) {
return NextResponse.json({ error: 'Zabbix not configured' }, { status: 500 });
}
const zabbix = new ZabbixClient({
apiUrl: process.env.ZABBIX_API_URL,
apiToken: process.env.ZABBIX_API_TOKEN,
});
try {
const hosts = await zabbix.getHosts();
// Fetch open problems to stamp last_problem_at
const problems = await zabbix.getOpenProblems(2);
const problemByHostId = new Map<string, { name: string; clock: string }>();
for (const p of problems) {
for (const h of (p.hosts ?? [])) {
if (!problemByHostId.has(h.hostid) || Number(p.clock) > Number(problemByHostId.get(h.hostid)!.clock)) {
problemByHostId.set(h.hostid, { name: p.name, clock: p.clock });
}
}
}
let upserted = 0;
let skipped = 0;
for (const host of hosts) {
// Only process ICMP/WAN hosts — they have an agent interface with an IP
const iface = (host.interfaces ?? []).find(i => Number(i.useip) === 1 && i.ip && i.ip !== '127.0.0.1');
if (!iface) { skipped++; continue; }
const getMacro = (name: string) =>
(host.macros ?? []).find(m => m.macro === name)?.value ?? null;
const getTag = (name: string) =>
(host.tags ?? []).find(t => t.tag === name)?.value ?? null;
const rmmSiteUid = getMacro('{$RMM_SITE_UID}');
const autotaskCompanyId = getMacro('{$AUTOTASK_COMPANY_ID}');
const autotaskCompanyName = getMacro('{$AUTOTASK_COMPANY_NAME}');
const ispName = getMacro('{$ISP_NAME}');
const asn = getMacro('{$ASN}');
const isMultiWan = getTag('multi-wan') === 'true';
const source = getTag('source') ?? 'datto-rmm';
const problem = problemByHostId.get(host.hostid);
await postgresClient.query(
`INSERT INTO zabbix_wan_hosts (
hostid, host_name, display_name, wan_ip, status,
rmm_site_uid, autotask_company_id, autotask_company_name,
isp_name, asn, is_multi_wan, source, tags,
last_problem_at, last_problem_name,
last_synced_at, updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),NOW())
ON CONFLICT (hostid) DO UPDATE SET
host_name = EXCLUDED.host_name,
display_name = EXCLUDED.display_name,
wan_ip = EXCLUDED.wan_ip,
status = EXCLUDED.status,
rmm_site_uid = EXCLUDED.rmm_site_uid,
autotask_company_id = EXCLUDED.autotask_company_id,
autotask_company_name = EXCLUDED.autotask_company_name,
isp_name = EXCLUDED.isp_name,
asn = EXCLUDED.asn,
is_multi_wan = EXCLUDED.is_multi_wan,
source = EXCLUDED.source,
tags = EXCLUDED.tags,
last_problem_at = EXCLUDED.last_problem_at,
last_problem_name = EXCLUDED.last_problem_name,
last_synced_at = NOW(),
updated_at = NOW()`,
[
host.hostid,
host.host,
host.name ?? host.host,
iface.ip,
Number(host.status ?? 0),
rmmSiteUid,
autotaskCompanyId ? Number(autotaskCompanyId) : null,
autotaskCompanyName,
ispName,
asn,
isMultiWan,
source,
JSON.stringify(host.tags ?? []),
problem ? new Date(Number(problem.clock) * 1000).toISOString() : null,
problem?.name ?? null,
]
);
upserted++;
}
// Remove hosts no longer in Zabbix
const liveIds = hosts.map(h => h.hostid);
if (liveIds.length > 0) {
const deleted = await postgresClient.query(
'DELETE FROM zabbix_wan_hosts WHERE hostid <> ALL($1) RETURNING hostid',
[liveIds]
);
return NextResponse.json({ upserted, skipped, removed: deleted.rowCount });
}
return NextResponse.json({ upserted, skipped, removed: 0 });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,166 @@
/**
* GET /api/zabbix/wan-gap-analysis
* Cross-references Datto RMM sites, Zabbix WAN hosts, and IT Glue WAN circuits
* to surface monitoring gaps and event correlation.
*
* Returns:
* - summary: counts of covered/gap/no-itg sites
* - rmm_gaps: RMM sites with no Zabbix host
* - itg_gaps: IT Glue WAN circuits with no matched Zabbix host (and have a static IP)
* - multi_circuit_sites: companies with >1 IT Glue WAN circuit, showing Zabbix coverage per circuit
* - current_problems: Zabbix hosts with active problems + matching recent RMM alerts
* - last_synced: timestamps of the cache tables
*/
import { NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function GET() {
try {
// ── 1. RMM sites missing from Zabbix ─────────────────────────────────────
const rmmGaps = await postgresClient.query(`
SELECT
s.uid AS rmm_site_uid,
s.name AS rmm_site_name,
s.autotask_company_name AS company_name,
rsm.company_id,
s.number_of_devices,
s.number_of_online_devices
FROM datto_rmm_sites s
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = s.uid
LEFT JOIN zabbix_wan_hosts z ON z.rmm_site_uid = s.uid
WHERE z.hostid IS NULL
AND s.number_of_online_devices > 0
ORDER BY s.autotask_company_name, s.name
`);
// ── 2. IT Glue WAN circuits missing from Zabbix (have IPs, not decommissioned) ──
const itgGaps = await postgresClient.query(`
SELECT
ic.id AS itg_asset_id,
ic.org_name,
ic.autotask_company_id,
ic.provider,
ic.link_type,
ic.static_ips,
ic.location_name,
ic.location_city,
ic.upload_mbps,
ic.download_mbps
FROM itg_wan_circuits ic
WHERE ic.zabbix_hostid IS NULL
AND ic.is_decommissioned = FALSE
AND array_length(ic.static_ips, 1) > 0
ORDER BY ic.org_name, ic.provider
`);
// ── 3. Multi-circuit companies (>1 IT Glue WAN circuit) ──────────────────
const multiCircuit = await postgresClient.query(`
SELECT
ic.org_name,
ic.autotask_company_id,
COUNT(*) AS total_circuits,
COUNT(*) FILTER (WHERE ic.zabbix_hostid IS NOT NULL) AS monitored_circuits,
COUNT(*) FILTER (WHERE ic.zabbix_hostid IS NULL
AND NOT ic.is_decommissioned
AND array_length(ic.static_ips,1) > 0) AS gap_circuits,
json_agg(json_build_object(
'id', ic.id,
'provider', ic.provider,
'link_type', ic.link_type,
'static_ips', ic.static_ips,
'location_name', ic.location_name,
'zabbix_hostid', ic.zabbix_hostid,
'is_decommissioned', ic.is_decommissioned
) ORDER BY ic.provider) AS circuits
FROM itg_wan_circuits ic
WHERE NOT ic.is_decommissioned
GROUP BY ic.org_name, ic.autotask_company_id
HAVING COUNT(*) > 1
ORDER BY gap_circuits DESC, ic.org_name
`);
// ── 4. Current Zabbix problems + correlated RMM alerts ───────────────────
const problems = await postgresClient.query(`
SELECT
z.hostid,
z.display_name,
z.wan_ip,
z.isp_name,
z.autotask_company_name,
z.autotask_company_id,
z.rmm_site_uid,
z.last_problem_at,
z.last_problem_name,
-- Count open RMM alerts for the same company in the last 24h
(
SELECT COUNT(*) FROM datto_rmm_alerts a
JOIN datto_rmm_sites ds ON ds.uid = a.site_uid
WHERE ds.autotask_company_id = z.autotask_company_id
AND a.resolved = FALSE
AND a.timestamp > NOW() - INTERVAL '24 hours'
) AS open_rmm_alerts_24h,
-- Most recent RMM network alert for this company
(
SELECT a.alert_message_en FROM datto_rmm_alerts a
JOIN datto_rmm_sites ds ON ds.uid = a.site_uid
WHERE ds.autotask_company_id = z.autotask_company_id
AND (a.alert_category ILIKE '%network%' OR a.alert_category ILIKE '%wan%'
OR a.alert_type ILIKE '%ping%' OR a.alert_type ILIKE '%offline%')
AND a.timestamp > NOW() - INTERVAL '24 hours'
ORDER BY a.timestamp DESC LIMIT 1
) AS latest_rmm_network_alert,
-- Open ticket count for this company today
(
SELECT COUNT(*) FROM tickets t
WHERE t.company_id = z.autotask_company_id
AND t.source = 8
AND t.create_date > NOW() - INTERVAL '24 hours'
AND t.is_deleted IS NOT TRUE
) AS monitor_tickets_24h
FROM zabbix_wan_hosts z
WHERE z.last_problem_at > NOW() - INTERVAL '24 hours'
OR z.status = 1
ORDER BY z.last_problem_at DESC NULLS LAST
`);
// ── 5. Summary counts ─────────────────────────────────────────────────────
const summary = await postgresClient.query(`
SELECT
(SELECT COUNT(*) FROM datto_rmm_sites s JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = s.uid)
AS total_rmm_sites,
(SELECT COUNT(*) FROM zabbix_wan_hosts)
AS total_zabbix_hosts,
(SELECT COUNT(*) FROM zabbix_wan_hosts WHERE status = 0)
AS zabbix_enabled,
(SELECT COUNT(*) FROM datto_rmm_sites s
JOIN rmm_site_mappings rsm ON rsm.rmm_site_uid = s.uid
LEFT JOIN zabbix_wan_hosts z ON z.rmm_site_uid = s.uid
WHERE z.hostid IS NULL AND s.number_of_online_devices > 0)
AS rmm_sites_no_zabbix,
(SELECT COUNT(*) FROM itg_wan_circuits WHERE NOT is_decommissioned)
AS total_itg_circuits,
(SELECT COUNT(*) FROM itg_wan_circuits
WHERE zabbix_hostid IS NOT NULL AND NOT is_decommissioned)
AS itg_circuits_monitored,
(SELECT COUNT(*) FROM itg_wan_circuits
WHERE zabbix_hostid IS NULL AND NOT is_decommissioned
AND array_length(static_ips,1) > 0)
AS itg_circuits_gap,
(SELECT MAX(last_synced_at) FROM zabbix_wan_hosts)
AS zabbix_last_synced,
(SELECT MAX(last_synced_at) FROM itg_wan_circuits)
AS itg_last_synced
`);
return NextResponse.json({
summary: summary.rows[0],
rmm_gaps: rmmGaps.rows,
itg_gaps: itgGaps.rows,
multi_circuit: multiCircuit.rows,
problems: problems.rows,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: msg }, { status: 500 });
}
}

View file

@ -0,0 +1,188 @@
/**
* POST /api/zabbix/webhook
* Receives Zabbix alert/recovery notifications and writes them to zabbix_events.
* Enriches each event with company/site context from the local zabbix_wan_hosts cache.
*
* Expected JSON payload (sent by the Zabbix webhook media type script):
* {
* event_id: string -- {EVENT.ID}
* event_name: string -- {EVENT.NAME}
* event_value: string -- "1" = PROBLEM, "0" = RESOLVED
* event_severity: string -- numeric severity "0""5"
* event_clock: string -- Unix timestamp string
* trigger_id: string -- {TRIGGER.ID}
* host_id: string -- {HOST.ID}
* host_name: string -- {HOST.HOST} (technical name)
* r_event_id?: string -- {EVENT.RECOVERY.ID} (only on recovery)
* r_clock?: string -- {EVENT.RECOVERY.DATE} as unix ts (only on recovery)
* }
*
* Auth: Bearer token in Authorization header, matched against ZABBIX_WEBHOOK_SECRET env var.
*/
import { NextRequest, NextResponse } from 'next/server';
import { postgresClient } from '@/lib/services/postgres-client';
export async function POST(request: NextRequest) {
// Optional shared secret — skip check if not configured
const secret = process.env.ZABBIX_WEBHOOK_SECRET;
if (secret) {
const auth = request.headers.get('authorization') ?? '';
const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth;
if (token !== secret) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
let body: Record<string, string>;
try {
const raw = await request.text();
console.log('[ZABBIX-WEBHOOK] Incoming request — auth:', request.headers.get('authorization') ? 'present' : 'none', '— body:', raw.substring(0, 500));
body = JSON.parse(raw);
} catch {
console.log('[ZABBIX-WEBHOOK] Failed to parse JSON');
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const {
event_id,
event_name,
event_value,
event_severity,
event_clock,
trigger_id,
host_id,
r_event_id,
r_clock,
} = body;
if (!event_id || !host_id || !event_clock) {
return NextResponse.json({ error: 'Missing required fields: event_id, host_id, event_clock' }, { status: 400 });
}
try {
// Enrich with local host context
const hostRow = await postgresClient.query<{
display_name: string;
wan_ip: string | null;
autotask_company_id: number | null;
autotask_company_name: string | null;
rmm_site_uid: string | null;
isp_name: string | null;
}>(
'SELECT display_name, wan_ip, autotask_company_id, autotask_company_name, rmm_site_uid, isp_name FROM zabbix_wan_hosts WHERE hostid = $1',
[host_id]
);
const host = hostRow.rows[0] ?? null;
const toInt = (v: string | undefined) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
const toTs = (v: string | undefined) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null; };
const isMacro = (v: string | undefined) => !v || v.startsWith('{');
const isResolved = event_value === '0';
const clockTs = toTs(event_clock) ?? new Date();
// {EVENT.RECOVERY.CLOCK} often doesn't resolve in webhook params — fall back to NOW() for recoveries
const rClockTs = toTs(r_clock) ?? (isResolved ? new Date() : null);
const duration = rClockTs ? Math.round((rClockTs.getTime() - clockTs.getTime()) / 1000) : null;
// {EVENT.RECOVERY.ID} also may not resolve — use event_id as marker so the row is flagged resolved
const rEventId = !isMacro(r_event_id) && r_event_id !== '0'
? r_event_id
: (isResolved ? event_id : null);
await postgresClient.query(
`INSERT INTO zabbix_events (
eventid, objectid, name, severity, clock,
r_eventid, r_clock, duration_seconds,
hostid, host_name, wan_ip,
autotask_company_id, autotask_company_name, rmm_site_uid, isp_name,
last_synced_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW())
ON CONFLICT (eventid) DO UPDATE SET
name = EXCLUDED.name,
r_eventid = COALESCE(EXCLUDED.r_eventid, zabbix_events.r_eventid),
r_clock = COALESCE(EXCLUDED.r_clock, zabbix_events.r_clock),
duration_seconds = COALESCE(EXCLUDED.duration_seconds, zabbix_events.duration_seconds),
host_name = EXCLUDED.host_name,
wan_ip = EXCLUDED.wan_ip,
autotask_company_id = EXCLUDED.autotask_company_id,
autotask_company_name = EXCLUDED.autotask_company_name,
rmm_site_uid = EXCLUDED.rmm_site_uid,
isp_name = EXCLUDED.isp_name,
last_synced_at = NOW()`,
[
event_id,
trigger_id ?? null,
event_name ?? null,
toInt(event_severity),
clockTs,
rEventId,
rClockTs,
duration,
host_id,
host?.display_name ?? body.host_name ?? null,
host?.wan_ip ?? null,
host?.autotask_company_id ?? null,
host?.autotask_company_name ?? null,
host?.rmm_site_uid ?? null,
host?.isp_name ?? null,
]
);
return NextResponse.json({ ok: true, event_id, resolved: isResolved });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error('[ZABBIX-WEBHOOK]', msg);
return NextResponse.json({ error: msg }, { status: 500 });
}
}
/**
* GET /api/zabbix/webhook
* Returns the Zabbix media type script and parameter config ready to paste.
*/
export async function GET() {
const baseUrl = process.env.WEBHOOK_BASE_URL ?? process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? 'https://your-pulse-url';
const secret = process.env.ZABBIX_WEBHOOK_SECRET ?? '';
const script = `// Pulse WAN Correlation Webhook
var params = JSON.parse(value);
var req = new HttpRequest();
req.addHeader('Content-Type: application/json');
${secret ? "req.addHeader('Authorization: Bearer ' + params.webhook_secret);" : '// No auth configured — set ZABBIX_WEBHOOK_SECRET env var to enable'}
var payload = JSON.stringify({
event_id: params.event_id,
event_name: params.event_name,
event_value: params.event_value,
event_severity: params.event_severity,
event_clock: params.event_clock,
trigger_id: params.trigger_id,
host_id: params.host_id,
host_name: params.host_name,
r_event_id: params.r_event_id,
r_clock: params.r_clock
});
var response = req.post(params.webhook_url, payload);
if (req.getStatus() !== 200) {
throw 'Pulse webhook failed: HTTP ' + req.getStatus() + ' — ' + response;
}
return 'OK';`;
const parameters = [
{ name: 'webhook_url', value: `${baseUrl}/api/zabbix/webhook` },
{ name: 'webhook_secret', value: secret || '(set ZABBIX_WEBHOOK_SECRET env var)' },
{ name: 'event_id', value: '{EVENT.ID}' },
{ name: 'event_name', value: '{EVENT.NAME}' },
{ name: 'event_value', value: '{EVENT.VALUE}' },
{ name: 'event_severity', value: '{EVENT.SEVERITY.NUM}' },
{ name: 'event_clock', value: '{EVENT.CLOCK}' },
{ name: 'trigger_id', value: '{TRIGGER.ID}' },
{ name: 'host_id', value: '{HOST.ID}' },
{ name: 'host_name', value: '{HOST.HOST}' },
{ name: 'r_event_id', value: '{EVENT.RECOVERY.ID}' },
{ name: 'r_clock', value: '{EVENT.RECOVERY.CLOCK}' },
];
return NextResponse.json({ script, parameters, webhook_url: `${baseUrl}/api/zabbix/webhook` });
}

67
app/legal/eula/page.tsx Normal file
View file

@ -0,0 +1,67 @@
export default function EndUserLicenseAgreement() {
return (
<main style={{ maxWidth: 800, margin: '40px auto', padding: '0 24px', fontFamily: 'sans-serif', lineHeight: 1.7 }}>
<h1>End-User License Agreement</h1>
<p><strong>Last updated:</strong> March 17, 2026</p>
<p>
This End-User License Agreement (&ldquo;Agreement&rdquo;) governs your use of the Pulse
internal operations platform (&ldquo;Application&rdquo;) developed and operated by{' '}
<strong>Wulf Consulting, Inc.</strong> (&ldquo;Wulf Consulting&rdquo;).
</p>
<h2>1. Internal Use Only</h2>
<p>
The Application is licensed exclusively for internal use by authorized Wulf Consulting
employees and contractors. Access by unauthorized individuals is strictly prohibited.
</p>
<h2>2. License Grant</h2>
<p>
Wulf Consulting grants authorized users a non-exclusive, non-transferable, revocable
license to access and use the Application solely for internal business operations.
</p>
<h2>3. Third-Party Integrations</h2>
<p>
The Application integrates with QuickBooks Online via the Intuit API. Use of QuickBooks
Online data within the Application is subject to Intuit&apos;s Terms of Service. Users
must not use the Application to access QuickBooks data beyond what is required for
legitimate internal business purposes.
</p>
<h2>4. Restrictions</h2>
<ul>
<li>You may not redistribute, sublicense, or resell access to the Application.</li>
<li>You may not use the Application to process data for third parties outside Wulf Consulting.</li>
<li>You may not reverse-engineer or attempt to extract source code from the Application.</li>
</ul>
<h2>5. Data Handling</h2>
<p>
All financial data accessed through the Application is handled in accordance with our
Privacy Policy. Data is stored on Wulf Consulting&apos;s private infrastructure and is
not shared externally.
</p>
<h2>6. Termination</h2>
<p>
This license is effective until terminated. Wulf Consulting may terminate access at any
time. Upon termination, you must cease all use of the Application.
</p>
<h2>7. Disclaimer of Warranties</h2>
<p>
The Application is provided &ldquo;as is&rdquo; for internal operational use. Wulf
Consulting makes no warranties regarding uptime, accuracy of synced data, or fitness for
any particular purpose beyond internal operations.
</p>
<h2>8. Contact</h2>
<p>
For questions about this Agreement, contact{' '}
<a href="mailto:admin@wulfconsulting.com">admin@wulfconsulting.com</a>.
</p>
</main>
);
}

View file

@ -0,0 +1,49 @@
export default function PrivacyPolicy() {
return (
<main style={{ maxWidth: 800, margin: '40px auto', padding: '0 24px', fontFamily: 'sans-serif', lineHeight: 1.7 }}>
<h1>Privacy Policy</h1>
<p><strong>Last updated:</strong> March 17, 2026</p>
<p>
Pulse is an internal operations platform operated by <strong>Wulf Consulting, Inc.</strong>
(&ldquo;Wulf Consulting&rdquo;, &ldquo;we&rdquo;, &ldquo;us&rdquo;). This application is for
internal business use only and is not available to the general public.
</p>
<h2>Information We Access</h2>
<p>
Pulse connects to QuickBooks Online via the Intuit OAuth 2.0 API to read financial data
including invoices, payments, deposits, transactions, and financial reports. This data is
accessed solely for internal reporting and business operations purposes.
</p>
<h2>How We Use Your Data</h2>
<ul>
<li>Financial data is stored in a private, self-hosted PostgreSQL database.</li>
<li>Data is used exclusively for internal dashboards and reporting.</li>
<li>No financial data is shared with third parties.</li>
<li>No data is sold or used for advertising.</li>
</ul>
<h2>Data Storage and Security</h2>
<p>
All data is stored on servers controlled by Wulf Consulting. Access is restricted to
authorized Wulf Consulting staff only. OAuth tokens are stored securely and are never
exposed externally.
</p>
<h2>Data Retention</h2>
<p>
Synced financial data is retained for operational reporting purposes. OAuth tokens are
refreshed automatically and can be revoked at any time via the QuickBooks Online app
authorization settings.
</p>
<h2>Contact</h2>
<p>
For questions about this policy, contact <strong>Wulf Consulting, Inc.</strong> at{' '}
<a href="mailto:admin@wulfconsulting.com">admin@wulfconsulting.com</a>.
</p>
</main>
);
}

View file

@ -24,6 +24,8 @@ import {
Users,
TrendingUp,
Sun,
BarChart3,
DollarSign,
} from 'lucide-react';
import {
NavigationMenu,
@ -146,6 +148,12 @@ const navigationItems: NavItem[] = [
icon: Sun,
description: 'Daily Zabbix overnight summary posted to Teams channels via webhook'
},
{
title: 'Ticket Digest Reports',
href: '/admin/ticket-digest',
icon: BarChart3,
description: 'LLM-analyzed ticket reports — noise, SLA, workload — daily/weekly/monthly'
},
{
title: 'Notification Channels',
href: '/admin/workflow/channels',
@ -164,6 +172,12 @@ const navigationItems: NavItem[] = [
icon: Shield,
description: 'SentinelOne EDR — sites, agents, threats sync'
},
{
title: 'QuickBooks Online',
href: '/admin/qbo',
icon: DollarSign,
description: 'Sync invoices, payments, deposits, transactions and financial reports'
},
{
title: 'Data Browser',
href: '/admin/data-browser',

View file

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

View file

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

View file

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

View file

@ -20,3 +20,4 @@ import './rmm-quick-job';
import './enrich-vspc';
import './db-query';
import './fetch-b2-result';
import './ping-flap-suppress';

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

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

View file

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

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

View file

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

View file

@ -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,
};
/**

View file

@ -0,0 +1,17 @@
-- Ping Flap Suppression Table
-- Tracks ping targets that have been auto-suppressed due to flapping behaviour.
-- Used to prevent repeated Zabbix suppression calls and pipeline noise.
CREATE TABLE IF NOT EXISTS ping_flap_suppressions (
ping_target TEXT PRIMARY KEY,
trigger_count INTEGER NOT NULL,
suppressed_until TIMESTAMP NOT NULL,
zabbix_suppressed BOOLEAN NOT NULL DEFAULT false,
notes TEXT,
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ping_flap_suppressions_until ON ping_flap_suppressions(suppressed_until);
COMMENT ON TABLE ping_flap_suppressions IS 'Tracks auto-suppressed flapping ping targets. Suppression expires at suppressed_until.';

View file

@ -0,0 +1,53 @@
-- Ticket Digest Report tables
-- Stores config, delivery webhooks, and report history for LLM-analyzed ticket digest reports
CREATE TABLE IF NOT EXISTS ticket_digest_config (
id INTEGER PRIMARY KEY DEFAULT 1,
daily_enabled BOOLEAN NOT NULL DEFAULT false,
weekly_enabled BOOLEAN NOT NULL DEFAULT false,
monthly_enabled BOOLEAN NOT NULL DEFAULT false,
daily_cron VARCHAR(50) NOT NULL DEFAULT '0 7 * * 1-5',
weekly_cron VARCHAR(50) NOT NULL DEFAULT '0 7 * * 1',
monthly_cron VARCHAR(50) NOT NULL DEFAULT '0 7 1 * *',
llm_provider VARCHAR(20) NOT NULL DEFAULT 'anthropic',
llm_model VARCHAR(100) NOT NULL DEFAULT 'claude-sonnet-4-20250514',
include_noise_analysis BOOLEAN NOT NULL DEFAULT true,
include_sla_analysis BOOLEAN NOT NULL DEFAULT true,
include_resource_analysis BOOLEAN NOT NULL DEFAULT true,
include_client_analysis BOOLEAN NOT NULL DEFAULT true,
include_recommendations BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT single_config CHECK (id = 1)
);
CREATE TABLE IF NOT EXISTS ticket_digest_webhooks (
id SERIAL PRIMARY KEY,
label VARCHAR(200) NOT NULL,
webhook_url TEXT NOT NULL,
digest_types TEXT[] NOT NULL DEFAULT '{daily,weekly,monthly}',
enabled BOOLEAN NOT NULL DEFAULT true,
last_delivered_at TIMESTAMP,
last_status VARCHAR(20),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS ticket_digest_reports (
id SERIAL PRIMARY KEY,
period_type VARCHAR(10) NOT NULL, -- 'daily', 'weekly', 'monthly'
period_start TIMESTAMP NOT NULL,
period_end TIMESTAMP NOT NULL,
generated_at TIMESTAMP NOT NULL DEFAULT NOW(),
stats JSONB NOT NULL DEFAULT '{}',
llm_analysis TEXT,
card_payload JSONB,
delivery_status JSONB DEFAULT '{}',
tokens_used INTEGER,
processing_time_ms INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ticket_digest_reports_period ON ticket_digest_reports(period_type, period_start DESC);
-- Insert default config
INSERT INTO ticket_digest_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING;

View file

@ -0,0 +1,60 @@
-- Migration 050: Zabbix WAN host cache + IT Glue WAN circuit cache
-- Enables local gap analysis and event correlation without live API calls every time.
-- ────────────────────────────────────────────────────────────────
-- Zabbix WAN host cache
-- Populated by /api/zabbix/sync-hosts (syncs from live Zabbix API)
-- ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS zabbix_wan_hosts (
hostid VARCHAR(50) PRIMARY KEY,
host_name VARCHAR(255), -- technical/sanitized name (host field)
display_name VARCHAR(255), -- human-readable display name
wan_ip VARCHAR(45), -- ICMP interface IP
status INTEGER DEFAULT 0, -- 0=enabled, 1=disabled
rmm_site_uid VARCHAR(100), -- from {$RMM_SITE_UID} macro
autotask_company_id INTEGER, -- from {$AUTOTASK_COMPANY_ID} macro
autotask_company_name VARCHAR(255), -- from {$AUTOTASK_COMPANY_NAME} macro
isp_name VARCHAR(255), -- from {$ISP_NAME} macro
asn VARCHAR(50), -- from {$ASN} macro
is_multi_wan BOOLEAN DEFAULT FALSE,
source VARCHAR(50), -- 'datto-rmm', 'manual', etc.
tags JSONB,
last_problem_at TIMESTAMPTZ, -- most recent Zabbix problem clock
last_problem_name TEXT, -- most recent Zabbix problem name
last_synced_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_zabbix_wan_hosts_company ON zabbix_wan_hosts (autotask_company_id);
CREATE INDEX IF NOT EXISTS idx_zabbix_wan_hosts_rmm_site ON zabbix_wan_hosts (rmm_site_uid);
CREATE INDEX IF NOT EXISTS idx_zabbix_wan_hosts_ip ON zabbix_wan_hosts (wan_ip);
-- ────────────────────────────────────────────────────────────────
-- IT Glue WAN circuit cache
-- Populated by /api/itglue/sync-wan (parses flexible_asset_type_id=3794)
-- ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS itg_wan_circuits (
id BIGINT PRIMARY KEY, -- IT Glue flexible asset ID
organization_id BIGINT, -- IT Glue org ID
org_name VARCHAR(255),
autotask_company_id BIGINT, -- via itg_organizations.psa_id::bigint
provider VARCHAR(255),
link_type VARCHAR(100),
static_ips TEXT[], -- parsed public IPs from traits
raw_ip_text TEXT, -- original IT Glue text (for reference)
upload_mbps NUMERIC,
download_mbps NUMERIC,
location_name VARCHAR(255),
location_city VARCHAR(255),
notes TEXT,
is_decommissioned BOOLEAN DEFAULT FALSE,
zabbix_hostid VARCHAR(50), -- matched Zabbix host (NULL = gap)
last_synced_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_itg_wan_circuits_org ON itg_wan_circuits (organization_id);
CREATE INDEX IF NOT EXISTS idx_itg_wan_circuits_company ON itg_wan_circuits (autotask_company_id);
CREATE INDEX IF NOT EXISTS idx_itg_wan_circuits_zabbix ON itg_wan_circuits (zabbix_hostid);

View file

@ -0,0 +1,125 @@
-- QBO OAuth2 tokens (single row for Wulf Consulting's QBO)
CREATE TABLE IF NOT EXISTS qbo_tokens (
id SERIAL PRIMARY KEY,
realm_id TEXT NOT NULL UNIQUE,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
access_token_expires_at TIMESTAMPTZ NOT NULL,
refresh_token_expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Invoices
CREATE TABLE IF NOT EXISTS qbo_invoices (
id TEXT PRIMARY KEY, -- QBO Id
realm_id TEXT NOT NULL,
doc_number TEXT,
txn_date DATE,
due_date DATE,
customer_ref_id TEXT,
customer_ref_name TEXT,
email_address TEXT,
total_amt NUMERIC(12,2),
balance NUMERIC(12,2),
status TEXT, -- Open, Paid, Voided, etc.
currency_code TEXT DEFAULT 'USD',
line_items JSONB,
linked_txns JSONB,
sync_token TEXT,
qbo_created_at TIMESTAMPTZ,
qbo_updated_at TIMESTAMPTZ,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_qbo_invoices_txn_date ON qbo_invoices(txn_date);
CREATE INDEX IF NOT EXISTS idx_qbo_invoices_customer ON qbo_invoices(customer_ref_id);
CREATE INDEX IF NOT EXISTS idx_qbo_invoices_status ON qbo_invoices(status);
-- Payments
CREATE TABLE IF NOT EXISTS qbo_payments (
id TEXT PRIMARY KEY, -- QBO Id
realm_id TEXT NOT NULL,
txn_date DATE,
customer_ref_id TEXT,
customer_ref_name TEXT,
total_amt NUMERIC(12,2),
unapplied_amt NUMERIC(12,2),
currency_code TEXT DEFAULT 'USD',
payment_method_ref TEXT,
deposit_account_ref TEXT,
linked_txns JSONB,
sync_token TEXT,
qbo_created_at TIMESTAMPTZ,
qbo_updated_at TIMESTAMPTZ,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_qbo_payments_txn_date ON qbo_payments(txn_date);
CREATE INDEX IF NOT EXISTS idx_qbo_payments_customer ON qbo_payments(customer_ref_id);
-- Deposits
CREATE TABLE IF NOT EXISTS qbo_deposits (
id TEXT PRIMARY KEY, -- QBO Id
realm_id TEXT NOT NULL,
txn_date DATE,
deposit_to_account_ref_id TEXT,
deposit_to_account_ref_name TEXT,
total_amt NUMERIC(12,2),
line_items JSONB,
sync_token TEXT,
qbo_created_at TIMESTAMPTZ,
qbo_updated_at TIMESTAMPTZ,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_qbo_deposits_txn_date ON qbo_deposits(txn_date);
-- General ledger transactions (purchases, expenses, journal entries, etc.)
CREATE TABLE IF NOT EXISTS qbo_transactions (
id TEXT NOT NULL,
txn_type TEXT NOT NULL, -- Purchase, Expense, JournalEntry, Transfer, etc.
realm_id TEXT NOT NULL,
txn_date DATE,
doc_number TEXT,
entity_ref_id TEXT, -- Vendor/Customer ref
entity_ref_name TEXT,
entity_type TEXT, -- Vendor, Customer
account_ref_id TEXT,
account_ref_name TEXT,
total_amt NUMERIC(12,2),
currency_code TEXT DEFAULT 'USD',
private_note TEXT,
line_items JSONB,
sync_token TEXT,
qbo_created_at TIMESTAMPTZ,
qbo_updated_at TIMESTAMPTZ,
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, txn_type)
);
CREATE INDEX IF NOT EXISTS idx_qbo_transactions_txn_date ON qbo_transactions(txn_date);
CREATE INDEX IF NOT EXISTS idx_qbo_transactions_type ON qbo_transactions(txn_type);
CREATE INDEX IF NOT EXISTS idx_qbo_transactions_entity ON qbo_transactions(entity_ref_id);
-- Financial reports: P&L and Balance Sheet stored as JSONB per period
CREATE TABLE IF NOT EXISTS qbo_reports (
id SERIAL PRIMARY KEY,
realm_id TEXT NOT NULL,
report_type TEXT NOT NULL, -- ProfitAndLoss, BalanceSheet
period_start DATE NOT NULL,
period_end DATE NOT NULL,
summarize_by TEXT DEFAULT 'Month',
report_data JSONB NOT NULL, -- Full report response
synced_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (realm_id, report_type, period_start, period_end)
);
CREATE INDEX IF NOT EXISTS idx_qbo_reports_type_period ON qbo_reports(report_type, period_start);
COMMENT ON TABLE qbo_tokens IS 'QuickBooks Online OAuth2 tokens for Wulf Consulting';
COMMENT ON TABLE qbo_invoices IS 'QBO invoices synced from QuickBooks Online';
COMMENT ON TABLE qbo_payments IS 'QBO customer payments synced from QuickBooks Online';
COMMENT ON TABLE qbo_deposits IS 'QBO bank deposits synced from QuickBooks Online';
COMMENT ON TABLE qbo_transactions IS 'QBO general transactions (purchases, expenses, journals) synced from QuickBooks Online';
COMMENT ON TABLE qbo_reports IS 'QBO financial reports (P&L, Balance Sheet) stored per period';

12
package-lock.json generated
View file

@ -58,6 +58,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "2.10.8",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4.1.18",
@ -5996,12 +5997,15 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz",
"integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==",
"version": "2.10.8",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz",
"integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/better-auth": {

View file

@ -59,6 +59,7 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0",
"baseline-browser-mapping": "2.10.8",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4.1.18",