From b98c67482aef3e59ab5c6b2560ed15f14735c693 Mon Sep 17 00:00:00 2001 From: lorentz Date: Tue, 17 Mar 2026 07:39:55 -0400 Subject: [PATCH] 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 --- LCI_FEATURE_DESIGN.md | 552 +++++++++++++ PULSE_DATABASE_SKILL.md | 525 ++++++++++++ app/admin/qbo/page.tsx | 246 ++++++ app/admin/ticket-digest/page.tsx | 456 ++++++++++ app/admin/zabbix-wan/page.tsx | 700 +++++++++++++++- app/api/itglue/sync-wan/route.ts | 151 ++++ app/api/kiosk/activity/route.ts | 1 + app/api/kiosk/stats/route.ts | 22 + app/api/qbo/auth/route.ts | 48 ++ app/api/qbo/disconnect/route.ts | 21 + app/api/qbo/sync/route.ts | 83 ++ app/api/reports/ticket-digest/config/route.ts | 34 + app/api/reports/ticket-digest/route.ts | 70 ++ app/api/webhooks/datto-rmm/route.ts | 88 ++ app/api/zabbix/alert-correlation/route.ts | 154 ++++ app/api/zabbix/sync-events/route.ts | 122 +++ app/api/zabbix/sync-hosts/route.ts | 122 +++ app/api/zabbix/wan-gap-analysis/route.ts | 166 ++++ app/api/zabbix/webhook/route.ts | 188 +++++ app/legal/eula/page.tsx | 67 ++ app/legal/privacy/page.tsx | 49 ++ components/navigation/app-navigation.tsx | 14 + lib/services/autotask-webhook-manager.ts | 4 + lib/services/datto-rmm-client.ts | 24 + lib/services/entity-sync.ts | 46 +- lib/services/pipeline-steps/index.ts | 1 + .../pipeline-steps/ping-flap-suppress.ts | 142 ++++ lib/services/qbo-client.ts | 290 +++++++ lib/services/qbo-sync-service.ts | 522 ++++++++++++ lib/services/sync-scheduler.ts | 41 +- lib/services/ticket-digest-service.ts | 782 ++++++++++++++++++ lib/services/zabbix-client.ts | 71 ++ lib/types/qbo.ts | 161 ++++ lib/types/webhook.ts | 7 +- .../049_create_ping_flap_suppressions.sql | 17 + .../049_create_ticket_digest_tables.sql | 53 ++ migrations/050_create_zabbix_wan_tables.sql | 60 ++ migrations/051_create_qbo_tables.sql | 125 +++ package-lock.json | 12 +- package.json | 1 + 40 files changed, 6223 insertions(+), 15 deletions(-) create mode 100644 LCI_FEATURE_DESIGN.md create mode 100644 PULSE_DATABASE_SKILL.md create mode 100644 app/admin/qbo/page.tsx create mode 100644 app/admin/ticket-digest/page.tsx create mode 100644 app/api/itglue/sync-wan/route.ts create mode 100644 app/api/qbo/auth/route.ts create mode 100644 app/api/qbo/disconnect/route.ts create mode 100644 app/api/qbo/sync/route.ts create mode 100644 app/api/reports/ticket-digest/config/route.ts create mode 100644 app/api/reports/ticket-digest/route.ts create mode 100644 app/api/zabbix/alert-correlation/route.ts create mode 100644 app/api/zabbix/sync-events/route.ts create mode 100644 app/api/zabbix/sync-hosts/route.ts create mode 100644 app/api/zabbix/wan-gap-analysis/route.ts create mode 100644 app/api/zabbix/webhook/route.ts create mode 100644 app/legal/eula/page.tsx create mode 100644 app/legal/privacy/page.tsx create mode 100644 lib/services/pipeline-steps/ping-flap-suppress.ts create mode 100644 lib/services/qbo-client.ts create mode 100644 lib/services/qbo-sync-service.ts create mode 100644 lib/services/ticket-digest-service.ts create mode 100644 lib/types/qbo.ts create mode 100644 migrations/049_create_ping_flap_suppressions.sql create mode 100644 migrations/049_create_ticket_digest_tables.sql create mode 100644 migrations/050_create_zabbix_wan_tables.sql create mode 100644 migrations/051_create_qbo_tables.sql diff --git a/LCI_FEATURE_DESIGN.md b/LCI_FEATURE_DESIGN.md new file mode 100644 index 0000000..22c0563 --- /dev/null +++ b/LCI_FEATURE_DESIGN.md @@ -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 6–8 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. (6–8 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. diff --git a/PULSE_DATABASE_SKILL.md b/PULSE_DATABASE_SKILL.md new file mode 100644 index 0000000..cbad2a9 --- /dev/null +++ b/PULSE_DATABASE_SKILL.md @@ -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 \ | +| 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. diff --git a/app/admin/qbo/page.tsx b/app/admin/qbo/page.tsx new file mode 100644 index 0000000..544d815 --- /dev/null +++ b/app/admin/qbo/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [syncMessage, setSyncMessage] = useState(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 ( +
+ {/* Header */} +
+
+

QuickBooks Online

+

Sync invoices, payments, deposits, transactions and financial reports

+
+ +
+ + {/* Banner */} + {banner && ( +
+ {banner.type === 'success' ? : + banner.type === 'error' ? : + } + {banner.msg} + +
+ )} + + {/* Connection Status */} +
+
+

Connection Status

+ {loading ? ( + + ) : ( +
+ + {tokenBadge.label} +
+ )} +
+ + +
+ + {/* Sync Controls */} +
+

Sync

+
+ + +
+ {syncMessage && ( +

{syncMessage}

+ )} +
+ + {/* Entity Counts */} +
+

Synced Data

+ {loading ? ( +
+ Loading... +
+ ) : ( +
+ {ENTITY_META.map(({ key, label, icon: Icon, color }) => ( +
+ +
+
+ {fmtNum(status?.counts[key] ?? 0)} +
+
{label}
+
+ {fmtDate(status?.lastSync[key] ?? null)} +
+
+
+ ))} +
+ )} +
+
+ ); +} + +export default function QboAdminPage() { + return ( + Loading...}> + + + ); +} diff --git a/app/admin/ticket-digest/page.tsx b/app/admin/ticket-digest/page.tsx new file mode 100644 index 0000000..0ee4233 --- /dev/null +++ b/app/admin/ticket-digest/page.tsx @@ -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; + 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; + 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 ; + if (type === 'telegram') return ; + if (type === 'ntfy') return ; + return ; +} + +function PeriodIcon({ period }: { period: string }) { + if (period === 'daily') return ; + if (period === 'weekly') return ; + return ; +} + +export default function TicketDigestPage() { + const [config, setConfig] = useState(null); + const [channels, setChannels] = useState([]); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(null); + const [toast, setToast] = useState<{ msg: string; ok: boolean } | null>(null); + const [expandedReport, setExpandedReport] = useState(null); + const [previewData, setPreviewData] = useState(null); + const [previewPeriod, setPreviewPeriod] = useState(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) => { + 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 ( +
+ +
+ ); + + return ( +
+ {/* Toast */} + {toast && ( +
+ {toast.msg} +
+ )} + + {/* Header */} +
+
+

+ Ticket Digest Reports +

+

+ LLM-analyzed ticket reports delivered to Teams — daily, weekly, and monthly +

+
+ +
+ + {/* Generate Reports */} +
+

+ Generate Report +

+
+ {(['daily', 'weekly', 'monthly'] as const).map(p => ( +
+
+ + {p} +
+
+ + +
+
+ ))} +
+ + {/* Preview */} + {previewPeriod && previewData && ( +
+
+

{previewPeriod} Preview — {previewData.period?.label}

+ +
+
+
+
{previewData.overview?.total_created ?? 0}
+
Created
+
+
+
{previewData.overview?.total_resolved ?? 0}
+
Resolved
+
+
+
{previewData.overview?.avg_resolution_hours ?? '—'}h
+
Avg Resolve
+
+
+
{(previewData.overview?.total_hours_worked ?? 0).toFixed(1)}h
+
Hours Worked
+
+
+ {previewData.noise_candidates?.length > 0 && ( +
+

🔁 Noise Candidates ({previewData.noise_candidates.length})

+
+ {previewData.noise_candidates.slice(0, 10).map((n: any, i: number) => ( +
+ {n.title} + {n.count}× · {n.source_label} +
+ ))} +
+
+ )} + {previewData.top_clients?.length > 0 && ( +
+

🏢 Top Clients

+
+ {previewData.top_clients.slice(0, 5).map((c: any, i: number) => ( +
+ {c.company_name} + {c.ticket_count} tickets · {c.hours_worked.toFixed(1)}h +
+ ))} +
+
+ )} +
+ )} +
+ + {/* Analysis Sections Config */} + {config && ( +
+

Analysis Sections

+
+ {([ + { 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 }) => ( + + ))} +
+
+ + +
+
+ )} + + {/* Notification Channels */} +
+
+
+

Delivery Channels

+

Select which notification channels receive these reports. Manage channels in Notification Channels.

+
+ + Manage Channels + +
+ + {channels.length === 0 ? ( +

+ No notification channels configured yet.{' '} + Add one here. +

+ ) : ( +
+ {channels.map(ch => { + const selected = config?.channel_ids?.includes(ch.id) ?? false; + return ( + + ); + })} +
+ )} + + {config && (config.channel_ids?.length ?? 0) === 0 && channels.length > 0 && ( +

⚠️ No channels selected — reports will be generated but not delivered.

+ )} +
+ + {/* History */} +
+

Report History

+ {history.length === 0 ? ( +

No reports generated yet. Generate your first report above.

+ ) : ( +
+ {history.map(report => { + const isExpanded = expandedReport === report.id; + const ov = report.stats?.overview; + return ( +
+ + {isExpanded && report.llm_analysis && ( +
+
+ {report.llm_analysis} +
+ {report.delivery_status && Object.keys(report.delivery_status).length > 0 && ( +
+ Delivery: + {Object.entries(report.delivery_status).map(([whId, st]) => ( + + #{whId}: {(st as any).success ? 'OK' : (st as any).error || 'Failed'} + + ))} +
+ )} +
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/app/admin/zabbix-wan/page.tsx b/app/admin/zabbix-wan/page.tsx index 7931978..38e075d 100644 --- a/app/admin/zabbix-wan/page.tsx +++ b/app/admin/zabbix-wan/page.tsx @@ -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('sync'); const [mode, setMode] = useState('all'); const [companyId, setCompanyId] = useState(''); const [siteUid, setSiteUid] = useState(''); @@ -122,6 +158,117 @@ export default function ZabbixWanPage() { const abortRef = useRef(null); const tableBottomRef = useRef(null); + // Gap analysis state + const [gapLoading, setGapLoading] = useState(false); + const [syncing, setSyncing] = useState<'zabbix' | 'itg' | null>(null); + const [gapSummary, setGapSummary] = useState(null); + const [rmmGaps, setRmmGaps] = useState([]); + const [itgGaps, setItgGaps] = useState([]); + const [multiCircuit, setMultiCircuit] = useState([]); + const [problems, setProblems] = useState([]); + const [gapError, setGapError] = useState(null); + const [expandedMulti, setExpandedMulti] = useState>(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(null); + const [zabbixEvents, setZabbixEvents] = useState([]); + const [rmmOnly, setRmmOnly] = useState([]); + const [corrError, setCorrError] = useState(null); + const [expandedEvent, setExpandedEvent] = useState>(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() {
{/* Header */}
- - -

Zabbix WAN Monitor Setup @@ -304,6 +446,29 @@ export default function ZabbixWanPage() {

+ {/* Tabs */} +
+ {([['sync', Globe, 'WAN Sync'], ['gaps', ShieldAlert, 'Gap Analysis'], ['correlation', Activity, 'Alert Correlation']] as const).map(([tab, Icon, label]) => ( + + ))} +
+ + {/* ── WAN Sync tab ─────────────────────────────────────────────────── */} + {activeTab === 'sync' && (<> {/* Config card */} @@ -732,6 +897,531 @@ export default function ZabbixWanPage() { Configure your options above and click {dryRun ? 'Preview' : 'Run'} to start.
)} + )} + + {/* ── Alert Correlation tab ──────────────────────────────────────── */} + {activeTab === 'correlation' && ( +
+ {/* Webhook setup */} + {webhookConfig && ( + + setWebhookCollapsed(v => !v)}> +
+ Zabbix Webhook Setup + {webhookCollapsed ? : } +
+ {webhookCollapsed && Click to expand setup instructions} + {!webhookCollapsed && Configure a Webhook media type in Zabbix → Administration → Media types, then create an action that sends to all WAN hosts} +
+ {!webhookCollapsed && +
+ {webhookConfig.webhook_url} + +
+
+
+

Media type script

+ +
+
{webhookConfig.script}
+
+
+

Parameters to add

+
+ {webhookConfig.parameters.map(p => ( +
+ {p.name} + {p.value} +
+ ))} +
+
+
} +
+ )} + + {/* Controls */} + + +
+
+ Alert Correlation + Compare Zabbix WAN events against Datto RMM ping/offline alerts for the same company and time window +
+ +
+
+ +
+
+ +
+ setCorrDays(Math.max(1,Number(e.target.value)))} className="w-20" /> + days +
+
+
+ +
+ setCorrWindow(Math.max(5,Number(e.target.value)))} className="w-20" /> + min ± +
+
+
+ {corrSummary && ( + <> +

Last event sync: {fmtSynced(corrSummary.last_event_sync)}

+
+

{corrSummary.zabbix_events_total}

Zabbix events

+
+

{corrSummary.zabbix_with_rmm_match}

+

Zabbix + RMM correlated

+
+
+

{corrSummary.zabbix_without_rmm_match}

+

Zabbix only (no RMM match)

+
+
+

{corrSummary.rmm_only_alerts}

+

RMM only (no Zabbix event)

+
+
+ + )} +
+
+ + {corrError && ( +
+ {corrError} +
+ )} + + {/* Zabbix events table */} + {(filteredEvents.length > 0 || corrSummary) && ( + + +
+ Zabbix WAN Events +
+ {(['all','matched','unmatched'] as const).map(f => ( + + ))} +
+
+
+ +
+ + + + Host / Client + WAN IP + Problem + Severity + Started + Duration + RMM Alerts + + + + {filteredEvents.map(ev => { + const matched = Number(ev.rmm_alert_count) > 0; + const expanded = expandedEvent.has(ev.eventid); + const alerts: RmmAlertRef[] = ev.rmm_alerts ?? []; + return (<> + setExpandedEvent(prev => { const n = new Set(prev); expanded ? n.delete(ev.eventid) : n.add(ev.eventid); return n; })} + > + +

{ev.host_name}

+

{ev.autotask_company_name}

+
+ {ev.wan_ip} + {ev.name} + {severityLabel(ev.severity)} + {fmtTs(ev.clock)} + {fmtDuration(ev.duration_seconds, !!ev.r_clock)} + + {matched + ? {ev.rmm_alert_count} + : 0} + +
+ {expanded && alerts.length > 0 && alerts.map(a => ( + + + {a.device_name && {a.device_name}} · {a.site_name} + + + {a.alert_class} + {a.alert_message ?? '—'} + + {fmtTs(a.timestamp)} + {a.resolved ? 'Resolved' : 'Open'} + + + ))} + {expanded && alerts.length === 0 && ( + + No RMM alerts found within ±{corrWindow}min for this company + + )} + ); + })} +
+
+
+
+
+ )} + + {/* RMM-only alerts */} + {rmmOnly.length > 0 && ( + + setRmmOnlyCollapsed(v => !v)}> +
+ + RMM-Only Alerts {rmmOnly.length} + + {rmmOnlyCollapsed ? : } +
+ RMM ping/offline alerts with no matching Zabbix event — potential Zabbix coverage gap +
+ {!rmmOnlyCollapsed && +
+ + + + Company + Site + Device + Type + Message + Time + Status + + + + {rmmOnly.map(a => ( + + {a.autotask_company_name ?? '—'} + {a.site_name} + {a.device_name ?? '—'} + {a.alert_class} + {a.alert_message ?? '—'} + {fmtTs(a.timestamp)} + {a.resolved ? 'Resolved' : 'Open'} + + ))} + +
+
+
} +
+ )} + + {!corrLoading && !corrSummary && !corrError && ( +
+ Click Refresh to load correlation data. +
+ )} +
+ )} + + {/* ── Gap Analysis tab ─────────────────────────────────────────────── */} + {activeTab === 'gaps' && ( +
+ {/* Sync controls */} + + +
+
+ Data Sources + Sync Zabbix hosts and IT Glue WAN circuits into local cache, then run gap analysis +
+ +
+
+ +
+ {/* Zabbix */} +
+
+
+

Zabbix Hosts

+

Last synced: {fmtSynced(gapSummary?.zabbix_last_synced ?? null)}

+
+ +
+ {gapSummary && ( +
+

Total hosts

{gapSummary.total_zabbix_hosts}

+

Enabled

{gapSummary.zabbix_enabled}

+
+ )} +
+ {/* IT Glue */} +
+
+
+

IT Glue WAN Circuits

+

Last synced: {fmtSynced(gapSummary?.itg_last_synced ?? null)}

+
+ +
+ {gapSummary && ( +
+

Total circuits

{gapSummary.total_itg_circuits}

+

Monitored

{gapSummary.itg_circuits_monitored}

+

Gaps

{gapSummary.itg_circuits_gap}

+
+ )} +
+
+ + {gapSummary && ( +
+
+

{gapSummary.total_rmm_sites}

+

RMM sites mapped

+
+
+

0 ? 'text-amber-500' : 'text-green-600'}`}> + {gapSummary.rmm_sites_no_zabbix} +

+

RMM sites without Zabbix host

+
+
+

0 ? 'text-amber-500' : 'text-green-600'}`}> + {gapSummary.itg_circuits_gap} +

+

IT Glue circuits unmonitored

+
+
+ )} +
+
+ + {gapError && ( +
+ {gapError} +
+ )} + + {/* Current Zabbix problems + RMM correlation */} + {problems.length > 0 && ( + + + Active / Recent Problems {problems.length} + Zabbix WAN problems in the last 24h correlated with RMM alerts and monitor tickets + + + + + + Host / Client + WAN IP + ISP + Problem + RMM Alerts 24h + Monitor Tickets 24h + Latest RMM Network Alert + + + + {problems.map(p => ( + + +

{p.display_name}

+

{p.autotask_company_name}

+
+ {p.wan_ip} + {p.isp_name ?? '—'} + {p.last_problem_name ?? '—'} + + 0 ? 'destructive' : 'outline'}>{p.open_rmm_alerts_24h} + + + 0 ? 'secondary' : 'outline'}>{p.monitor_tickets_24h} + + + {p.latest_rmm_network_alert ?? None} + +
+ ))} +
+
+
+
+ )} + + {/* Multi-circuit companies */} + {multiCircuit.length > 0 && ( + + + Multi-Circuit Companies + Companies with more than one WAN circuit in IT Glue — check each is covered in Zabbix + + + + + + Company + Circuits + Monitored + Gaps + + + + + {multiCircuit.map(mc => { + const key = mc.org_name; + const expanded = expandedMulti.has(key); + return (<> + 0 ? 'bg-amber-500/5' : ''}`} + onClick={() => setExpandedMulti(prev => { const n = new Set(prev); expanded ? n.delete(key) : n.add(key); return n; })} + > + {mc.org_name} + {mc.total_circuits} + 0 ? 'default' : 'outline'} className="bg-green-600">{mc.monitored_circuits} + {Number(mc.gap_circuits) > 0 ? {mc.gap_circuits} : 0} + {expanded ? : } + + {expanded && mc.circuits.map(c => ( + + + {c.provider} + {c.location_name && — {c.location_name}} + {c.is_decommissioned && Decommissioned} + + {c.link_type} + {c.static_ips?.join(', ') || '—'} + + {c.zabbix_hostid + ? Monitored + : c.is_decommissioned + ? Decommissioned + : No Zabbix Host + } + + + ))} + ); + })} + +
+
+
+ )} + + {/* IT Glue gaps */} + {itgGaps.length > 0 && ( + + + IT Glue Circuits Without Zabbix Monitoring {itgGaps.length} + Active circuits with documented static IPs in IT Glue that have no matching Zabbix host + + + + + + Organization + Provider + Type + Static IPs + Location + Speed + + + + {itgGaps.map(g => ( + + {g.org_name} + {g.provider ?? '—'} + {g.link_type ?? '—'} + {g.static_ips?.join(', ') || '—'} + {[g.location_name, g.location_city].filter(Boolean).join(', ') || '—'} + + {g.download_mbps ? `↓${g.download_mbps}` : ''}{g.upload_mbps ? ` ↑${g.upload_mbps} Mbps` : ''} + + + ))} + +
+
+
+ )} + + {/* RMM sites without Zabbix */} + {rmmGaps.length > 0 && ( + + + RMM Sites Without Zabbix Host {rmmGaps.length} + Online RMM sites that have no corresponding Zabbix WAN monitor — run WAN Sync to add them + + + + + + Site + Company + Devices + Online + + + + {rmmGaps.map(g => ( + + {g.rmm_site_name} + {g.company_name} + {g.number_of_devices} + {g.number_of_online_devices} + + ))} + +
+
+
+ )} + + {!gapLoading && gapSummary && rmmGaps.length === 0 && itgGaps.length === 0 && problems.length === 0 && ( +
+ + All monitored sites and IT Glue circuits are covered in Zabbix. +
+ )} + + {!gapLoading && !gapSummary && !gapError && ( +
+ Sync Zabbix hosts and IT Glue circuits above to run gap analysis. +
+ )} +
+ )} ); } diff --git a/app/api/itglue/sync-wan/route.ts b/app/api/itglue/sync-wan/route.ts new file mode 100644 index 0000000..fb65a24 --- /dev/null +++ b/app/api/itglue/sync-wan/route.ts @@ -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(/ /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(); + 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 }); + } +} diff --git a/app/api/kiosk/activity/route.ts b/app/api/kiosk/activity/route.ts index 579dede..d6c9e68 100644 --- a/app/api/kiosk/activity/route.ts +++ b/app/api/kiosk/activity/route.ts @@ -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 diff --git a/app/api/kiosk/stats/route.ts b/app/api/kiosk/stats/route.ts index 06675e4..0926f31 100644 --- a/app/api/kiosk/stats/route.ts +++ b/app/api/kiosk/stats/route.ts @@ -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)` ); diff --git a/app/api/qbo/auth/route.ts b/app/api/qbo/auth/route.ts new file mode 100644 index 0000000..fc2cb03 --- /dev/null +++ b/app/api/qbo/auth/route.ts @@ -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 }); + } +} diff --git a/app/api/qbo/disconnect/route.ts b/app/api/qbo/disconnect/route.ts new file mode 100644 index 0000000..d592035 --- /dev/null +++ b/app/api/qbo/disconnect/route.ts @@ -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)}`); + } +} diff --git a/app/api/qbo/sync/route.ts b/app/api/qbo/sync/route.ts new file mode 100644 index 0000000..3298aba --- /dev/null +++ b/app/api/qbo/sync/route.ts @@ -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 }); + } +} diff --git a/app/api/reports/ticket-digest/config/route.ts b/app/api/reports/ticket-digest/config/route.ts new file mode 100644 index 0000000..178c37b --- /dev/null +++ b/app/api/reports/ticket-digest/config/route.ts @@ -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 }); + } +} diff --git a/app/api/reports/ticket-digest/route.ts b/app/api/reports/ticket-digest/route.ts new file mode 100644 index 0000000..991d625 --- /dev/null +++ b/app/api/reports/ticket-digest/route.ts @@ -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 }); + } +} diff --git a/app/api/webhooks/datto-rmm/route.ts b/app/api/webhooks/datto-rmm/route.ts index b708fcd..375d244 100644 --- a/app/api/webhooks/datto-rmm/route.ts +++ b/app/api/webhooks/datto-rmm/route.ts @@ -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 { + 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 => diff --git a/app/api/zabbix/alert-correlation/route.ts b/app/api/zabbix/alert-correlation/route.ts new file mode 100644 index 0000000..e495a7c --- /dev/null +++ b/app/api/zabbix/alert-correlation/route.ts @@ -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 }); + } +} diff --git a/app/api/zabbix/sync-events/route.ts b/app/api/zabbix/sync-events/route.ts new file mode 100644 index 0000000..a3fa0b8 --- /dev/null +++ b/app/api/zabbix/sync-events/route.ts @@ -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 }); + } +} diff --git a/app/api/zabbix/sync-hosts/route.ts b/app/api/zabbix/sync-hosts/route.ts new file mode 100644 index 0000000..862a1df --- /dev/null +++ b/app/api/zabbix/sync-hosts/route.ts @@ -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(); + 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 }); + } +} diff --git a/app/api/zabbix/wan-gap-analysis/route.ts b/app/api/zabbix/wan-gap-analysis/route.ts new file mode 100644 index 0000000..f9d1824 --- /dev/null +++ b/app/api/zabbix/wan-gap-analysis/route.ts @@ -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 }); + } +} diff --git a/app/api/zabbix/webhook/route.ts b/app/api/zabbix/webhook/route.ts new file mode 100644 index 0000000..0d3f737 --- /dev/null +++ b/app/api/zabbix/webhook/route.ts @@ -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; + 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` }); +} diff --git a/app/legal/eula/page.tsx b/app/legal/eula/page.tsx new file mode 100644 index 0000000..1a123b4 --- /dev/null +++ b/app/legal/eula/page.tsx @@ -0,0 +1,67 @@ +export default function EndUserLicenseAgreement() { + return ( +
+

End-User License Agreement

+

Last updated: March 17, 2026

+ +

+ This End-User License Agreement (“Agreement”) governs your use of the Pulse + internal operations platform (“Application”) developed and operated by{' '} + Wulf Consulting, Inc. (“Wulf Consulting”). +

+ +

1. Internal Use Only

+

+ The Application is licensed exclusively for internal use by authorized Wulf Consulting + employees and contractors. Access by unauthorized individuals is strictly prohibited. +

+ +

2. License Grant

+

+ Wulf Consulting grants authorized users a non-exclusive, non-transferable, revocable + license to access and use the Application solely for internal business operations. +

+ +

3. Third-Party Integrations

+

+ The Application integrates with QuickBooks Online via the Intuit API. Use of QuickBooks + Online data within the Application is subject to Intuit's Terms of Service. Users + must not use the Application to access QuickBooks data beyond what is required for + legitimate internal business purposes. +

+ +

4. Restrictions

+
    +
  • You may not redistribute, sublicense, or resell access to the Application.
  • +
  • You may not use the Application to process data for third parties outside Wulf Consulting.
  • +
  • You may not reverse-engineer or attempt to extract source code from the Application.
  • +
+ +

5. Data Handling

+

+ All financial data accessed through the Application is handled in accordance with our + Privacy Policy. Data is stored on Wulf Consulting's private infrastructure and is + not shared externally. +

+ +

6. Termination

+

+ This license is effective until terminated. Wulf Consulting may terminate access at any + time. Upon termination, you must cease all use of the Application. +

+ +

7. Disclaimer of Warranties

+

+ The Application is provided “as is” for internal operational use. Wulf + Consulting makes no warranties regarding uptime, accuracy of synced data, or fitness for + any particular purpose beyond internal operations. +

+ +

8. Contact

+

+ For questions about this Agreement, contact{' '} + admin@wulfconsulting.com. +

+
+ ); +} diff --git a/app/legal/privacy/page.tsx b/app/legal/privacy/page.tsx new file mode 100644 index 0000000..c066d6f --- /dev/null +++ b/app/legal/privacy/page.tsx @@ -0,0 +1,49 @@ +export default function PrivacyPolicy() { + return ( +
+

Privacy Policy

+

Last updated: March 17, 2026

+ +

+ Pulse is an internal operations platform operated by Wulf Consulting, Inc. + (“Wulf Consulting”, “we”, “us”). This application is for + internal business use only and is not available to the general public. +

+ +

Information We Access

+

+ 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. +

+ +

How We Use Your Data

+
    +
  • Financial data is stored in a private, self-hosted PostgreSQL database.
  • +
  • Data is used exclusively for internal dashboards and reporting.
  • +
  • No financial data is shared with third parties.
  • +
  • No data is sold or used for advertising.
  • +
+ +

Data Storage and Security

+

+ 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. +

+ +

Data Retention

+

+ 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. +

+ +

Contact

+

+ For questions about this policy, contact Wulf Consulting, Inc. at{' '} + admin@wulfconsulting.com. +

+
+ ); +} diff --git a/components/navigation/app-navigation.tsx b/components/navigation/app-navigation.tsx index e59a503..0f35382 100644 --- a/components/navigation/app-navigation.tsx +++ b/components/navigation/app-navigation.tsx @@ -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', diff --git a/lib/services/autotask-webhook-manager.ts b/lib/services/autotask-webhook-manager.ts index 213c47b..9a36556 100644 --- a/lib/services/autotask-webhook-manager.ts +++ b/lib/services/autotask-webhook-manager.ts @@ -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`; } diff --git a/lib/services/datto-rmm-client.ts b/lib/services/datto-rmm-client.ts index ccee427..2503805 100644 --- a/lib/services/datto-rmm-client.ts +++ b/lib/services/datto-rmm-client.ts @@ -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 { + 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; + } } diff --git a/lib/services/entity-sync.ts b/lib/services/entity-sync.ts index 64c812e..7e2490c 100644 --- a/lib/services/entity-sync.ts +++ b/lib/services/entity-sync.ts @@ -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)'); diff --git a/lib/services/pipeline-steps/index.ts b/lib/services/pipeline-steps/index.ts index 96dd0bf..00b97e6 100644 --- a/lib/services/pipeline-steps/index.ts +++ b/lib/services/pipeline-steps/index.ts @@ -20,3 +20,4 @@ import './rmm-quick-job'; import './enrich-vspc'; import './db-query'; import './fetch-b2-result'; +import './ping-flap-suppress'; diff --git a/lib/services/pipeline-steps/ping-flap-suppress.ts b/lib/services/pipeline-steps/ping-flap-suppress.ts new file mode 100644 index 0000000..9c02527 --- /dev/null +++ b/lib/services/pipeline-steps/ping-flap-suppress.ts @@ -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 { + 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); diff --git a/lib/services/qbo-client.ts b/lib/services/qbo-client.ts new file mode 100644 index 0000000..3352668 --- /dev/null +++ b/lib/services/qbo-client.ts @@ -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 { + 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 { + const result = await postgresClient.query( + `SELECT * FROM qbo_tokens WHERE realm_id = $1 LIMIT 1`, + [this.realmId] + ); + return result.rows[0] || null; + } + + async saveToken(token: QboTokenResponse): Promise { + 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 { + 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 { + 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(path: string, options: RequestInit = {}): Promise { + 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; + } + + // ─── Paginated Query ───────────────────────────────────────────────────────── + + private async queryAll(entity: string, extraWhere = ''): Promise { + 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>(`/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 { + const where = updatedSince + ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` + : undefined; + return this.queryAll('Invoice', where); + } + + async getPayments(updatedSince?: Date): Promise { + const where = updatedSince + ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` + : undefined; + return this.queryAll('Payment', where); + } + + async getDeposits(updatedSince?: Date): Promise { + const where = updatedSince + ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` + : undefined; + return this.queryAll('Deposit', where); + } + + async getPurchases(updatedSince?: Date): Promise { + const where = updatedSince + ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` + : undefined; + return this.queryAll('Purchase', where); + } + + async getJournalEntries(updatedSince?: Date): Promise { + const where = updatedSince + ? `MetaData.LastUpdatedTime > '${updatedSince.toISOString()}'` + : undefined; + return this.queryAll('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 { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate, + accounting_method: accountingMethod ?? 'Accrual', + showrows: 'all', + showcols: 'all', + minorversion: '65', + }); + return this.request(`/reports/ProfitAndLoss?${params.toString()}`); + } + + async getBalanceSheet(startDate: string, endDate: string, accountingMethod?: string): Promise { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate, + accounting_method: accountingMethod ?? 'Accrual', + showrows: 'all', + showcols: 'all', + minorversion: '65', + }); + return this.request(`/reports/BalanceSheet?${params.toString()}`); + } + + async getCashFlow(startDate: string, endDate: string): Promise { + const params = new URLSearchParams({ + start_date: startDate, + end_date: endDate, + minorversion: '65', + }); + return this.request(`/reports/CashFlow?${params.toString()}`); + } +} + +let _instance: QboClient | null = null; + +export function getQboClient(): QboClient { + if (!_instance) { + _instance = new QboClient(); + } + return _instance; +} diff --git a/lib/services/qbo-sync-service.ts b/lib/services/qbo-sync-service.ts new file mode 100644 index 0000000..e270d44 --- /dev/null +++ b/lib/services/qbo-sync-service.ts @@ -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 { + return this.executeSync('full', triggeredBy); + } + + async incrementalSync(triggeredBy = 'system'): Promise { + return this.executeSync('incremental', triggeredBy); + } + + private async executeSync( + syncType: 'full' | 'incremental', + triggeredBy: string + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/lib/services/sync-scheduler.ts b/lib/services/sync-scheduler.ts index 5bbb097..fc5f5c3 100644 --- a/lib/services/sync-scheduler.ts +++ b/lib/services/sync-scheduler.ts @@ -15,13 +15,14 @@ import { isMsgraphConfigured } from './msgraph-factory'; import { ZoomSyncService } from './zoom-sync-service'; import { isZoomConfigured } from './zoom-factory'; import { MorningSummaryService } from './morning-summary-service'; +import { TicketDigestService } from './ticket-digest-service'; export interface ScheduleConfig { id: string; name: string; description: string; cron_expression: string; - sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary'; + sync_type: 'incremental' | 'full' | 'veeam-incremental' | 'veeam-full' | 'veeam-rpo-check' | 'contract-services' | 'engagement-daily' | 'zoom-daily' | 'morning-summary' | 'ticket-digest-daily' | 'ticket-digest-weekly' | 'ticket-digest-monthly'; years_back?: number; is_enabled: boolean; last_run?: Date; @@ -78,6 +79,14 @@ class SyncScheduler { return this._morningSummaryService; } + private _ticketDigestService?: TicketDigestService; + private getTicketDigestService(): TicketDigestService { + if (!this._ticketDigestService) { + this._ticketDigestService = new TicketDigestService(); + } + return this._ticketDigestService; + } + private getZoomSyncService(): ZoomSyncService { if (!this._zoomSyncService) { this._zoomSyncService = new ZoomSyncService(); @@ -242,6 +251,30 @@ class SyncScheduler { sync_type: 'morning-summary', is_enabled: false, }, + { + id: 'ticket-digest-daily', + name: 'Daily Ticket Digest', + description: 'LLM-analyzed ticket digest for the previous day, delivered to Teams at 7 AM Mon–Fri', + cron_expression: '0 7 * * 1-5', + sync_type: 'ticket-digest-daily', + is_enabled: false, + }, + { + id: 'ticket-digest-weekly', + name: 'Weekly Ticket Digest', + description: 'LLM-analyzed ticket digest for the previous week, delivered to Teams at 7 AM Monday', + cron_expression: '0 7 * * 1', + sync_type: 'ticket-digest-weekly', + is_enabled: false, + }, + { + id: 'ticket-digest-monthly', + name: 'Monthly Ticket Digest', + description: 'LLM-analyzed ticket digest for the previous month, delivered to Teams at 7 AM on the 1st', + cron_expression: '0 7 1 * *', + sync_type: 'ticket-digest-monthly', + is_enabled: false, + }, ]; for (const schedule of defaultSchedules) { @@ -367,6 +400,12 @@ class SyncScheduler { } } else if (config.sync_type === 'morning-summary') { await this.getMorningSummaryService().run(); + } else if (config.sync_type === 'ticket-digest-daily') { + await this.getTicketDigestService().run('daily'); + } else if (config.sync_type === 'ticket-digest-weekly') { + await this.getTicketDigestService().run('weekly'); + } else if (config.sync_type === 'ticket-digest-monthly') { + await this.getTicketDigestService().run('monthly'); } else if (config.sync_type === 'incremental') { await this.syncService.incrementalSync('scheduled'); } else { diff --git a/lib/services/ticket-digest-service.ts b/lib/services/ticket-digest-service.ts new file mode 100644 index 0000000..443cf0b --- /dev/null +++ b/lib/services/ticket-digest-service.ts @@ -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; + 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 = { + '-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 = { + 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 { + const r = await postgresClient.query('SELECT * FROM ticket_digest_config WHERE id = 1'); + return r.rows[0] as DigestConfig; + } + + async updateConfig(updates: Partial): Promise { + 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 { + 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 { + 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 { + 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 => { + 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 = { '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 = {}; + 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> { + 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; +} diff --git a/lib/services/zabbix-client.ts b/lib/services/zabbix-client.ts index ad6ac0d..ebee2e9 100644 --- a/lib/services/zabbix-client.ts +++ b/lib/services/zabbix-client.ts @@ -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; + }>> { + // 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> { + return this.rpc>('host.get', { + output: ['hostid', 'name'], + filter: { ip: [dnsOrIp], dns: [dnsOrIp] }, + searchByAny: true, + }); + } + + /** + * Get open problems for a host. + */ + async getOpenProblemsForHost(hostid: string): Promise { + return this.rpc('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 { + await this.rpc<{ eventids: string[] }>('event.acknowledge', { + eventids: [eventid], + action: 20, // 4 (add message) + 16 (suppress) + message, + }); + } } diff --git a/lib/types/qbo.ts b/lib/types/qbo.ts new file mode 100644 index 0000000..6ff611e --- /dev/null +++ b/lib/types/qbo.ts @@ -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 { + 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; +} diff --git a/lib/types/webhook.ts b/lib/types/webhook.ts index cf39ff3..87d3039 100644 --- a/lib/types/webhook.ts +++ b/lib/types/webhook.ts @@ -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 = { '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, }; /** diff --git a/migrations/049_create_ping_flap_suppressions.sql b/migrations/049_create_ping_flap_suppressions.sql new file mode 100644 index 0000000..99406eb --- /dev/null +++ b/migrations/049_create_ping_flap_suppressions.sql @@ -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.'; diff --git a/migrations/049_create_ticket_digest_tables.sql b/migrations/049_create_ticket_digest_tables.sql new file mode 100644 index 0000000..718c149 --- /dev/null +++ b/migrations/049_create_ticket_digest_tables.sql @@ -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; diff --git a/migrations/050_create_zabbix_wan_tables.sql b/migrations/050_create_zabbix_wan_tables.sql new file mode 100644 index 0000000..fe79376 --- /dev/null +++ b/migrations/050_create_zabbix_wan_tables.sql @@ -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); diff --git a/migrations/051_create_qbo_tables.sql b/migrations/051_create_qbo_tables.sql new file mode 100644 index 0000000..eedd800 --- /dev/null +++ b/migrations/051_create_qbo_tables.sql @@ -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'; diff --git a/package-lock.json b/package-lock.json index a1ec271..310df5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 8caa133..ff28730 100644 --- a/package.json +++ b/package.json @@ -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",