feat: Morning NOC Summary adaptive card for Teams

- Add MorningSummaryService with Zabbix aggregation and adaptive card builder
- Add webhook delivery system with Teams incoming webhooks
- Add admin UI at /admin/morning-summary for webhook/config management
- Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history
- Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM)
- Add outages_only filter (Unavailable triggers only)
- Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts
- Fix resolved events: event.get value:1 scoped to window with r_eventid filter
- Remove emojis from fact rows and section headers in card
- Remove Open Zabbix button (duplicate of View Problems)
- Add migrations: morning_summary_config + morning_summaries tables
- Add outages_only column to morning_summary_config
This commit is contained in:
lorentz 2026-03-11 09:34:51 -04:00
parent 19605f82aa
commit c518eefdb2
61 changed files with 11236 additions and 237 deletions

View file

@ -51,6 +51,12 @@ export function mapAutotaskToDatabase(
case EntityType.CONTRACTS:
mapped = mapContract(data);
break;
case EntityType.CONTRACT_SERVICES:
mapped = mapContractService(data);
break;
case EntityType.AUTOTASK_SERVICES:
mapped = mapAutotaskService(data);
break;
case EntityType.BILLING_ITEMS:
mapped = mapBillingItem(data);
break;
@ -518,6 +524,41 @@ function mapContract(data: any): Record<string, any> {
};
}
/**
* Map Autotask Service (catalog item) entity
*/
function mapAutotaskService(data: any): Record<string, any> {
return {
id: data.id,
name: data.name,
description: data.description,
unit_price: data.unitPrice,
unit_cost: data.unitCost,
period_type: data.periodType,
is_active: data.isActive !== undefined ? data.isActive : true,
synced_at: new Date(),
is_deleted: false,
};
}
/**
* Map Contract Service entity
*/
function mapContractService(data: any): Record<string, any> {
return {
id: data.id,
contract_id: data.contractID,
service_id: data.serviceID,
unit_price: data.unitPrice,
unit_cost: data.unitCost,
adjusted_price: data.internalCurrencyAdjustedPrice,
invoice_description: data.invoiceDescription,
internal_currency_price: data.internalCurrencyUnitPrice,
synced_at: new Date(),
is_deleted: false,
};
}
/**
* Map Billing Item entity
*/
@ -558,45 +599,32 @@ function mapBillingItem(data: any): Record<string, any> {
* Map Time Entry entity
*/
function mapTimeEntry(data: any): Record<string, any> {
// Autotask uses isNonBillable; derive billable from it
const isNonBillable = data.isNonBillable;
const billable = isNonBillable != null ? !isNonBillable : null;
return {
id: data.id,
resource_id: data.resourceID,
ticket_id: data.ticketID,
task_id: data.taskID,
project_id: data.projectID,
company_id: data.companyID,
entry_date: data.dateWorked, // Autotask API returns dateWorked
hours_worked: data.hoursWorked, // Autotask API returns hoursWorked
notes: data.summaryNotes, // Autotask uses summaryNotes
internal_notes: data.internalNotes,
title: data.title,
type: data.type,
start_date_time: data.startDateTime,
end_date_time: data.endDateTime,
billable: data.billable, // Autotask returns billable
billing_rate: data.billingRate,
billing_rate_currency_id: data.billingRateCurrencyID,
cost_rate: data.costRate,
cost_rate_currency_id: data.costRateCurrencyID,
cost: data.cost,
cost_currency_id: data.costCurrencyID,
revenue: data.revenue,
revenue_currency_id: data.revenueCurrencyID,
margin: data.margin,
margin_currency_id: data.marginCurrencyID,
approved: data.approved,
approved_by_resource_id: data.approvedByResourceID,
approved_date_time: data.approvedDateTime,
non_billable: data.nonBillable,
contract_id: data.contractID,
contract_service_id: data.contractServiceID,
contract_service_bundle_id: data.contractServiceBundleID,
entry_date: data.dateWorked,
hours_worked: data.hoursWorked,
hours_to_bill: data.hoursToBill,
notes: data.summaryNotes,
internal_notes: data.internalNotes,
type: data.timeEntryType,
start_date_time: data.startDateTime,
end_date_time: data.endDateTime,
billable,
non_billable: isNonBillable,
allocation_code_id: data.billingCodeID,
approved_by_resource_id: data.billingApprovalResourceID,
approved_date_time: data.billingApprovalDateTime,
role_id: data.roleID,
department_id: data.departmentID,
location_id: data.locationID,
allocation_code_id: data.allocationCodeID,
imp_project_schedule_id: data.impProjectScheduleID,
imp_project_schedule_task_id: data.impProjectScheduleTaskID,
api_vendor_id: data.apiVendorID,
synced_at: new Date(),
is_deleted: false,
};

View file

@ -65,11 +65,27 @@ export function getAllEntitiesInOrder(): EntityType[] {
EntityType.TASKS,
EntityType.CONFIGURATION_ITEMS,
EntityType.CONTRACTS,
EntityType.CONTRACT_SERVICES,
EntityType.AUTOTASK_SERVICES,
EntityType.BILLING_ITEMS,
EntityType.TIME_ENTRIES,
]);
}
/**
* Build filter for contract services (requires contractID filter fetch all via contractIDs)
* @returns Query filter array for active contract services
*/
export function buildContractServicesFilter(): Array<{ field: string; op: string; value: any }> {
return [
{
field: 'contractID',
op: 'gt',
value: 0,
},
];
}
/**
* Get table name for entity type
* @param entity Entity type
@ -102,6 +118,8 @@ export function getAutotaskEntityName(entity: EntityType): string {
[EntityType.CONFIGURATION_ITEMS]: 'ConfigurationItems',
[EntityType.CONTACTS]: 'Contacts',
[EntityType.CONTRACTS]: 'Contracts',
[EntityType.CONTRACT_SERVICES]: 'ContractServices',
[EntityType.AUTOTASK_SERVICES]: 'Services',
[EntityType.TIME_ENTRIES]: 'TimeEntries',
[EntityType.TICKET_NOTES]: 'TicketNotes',
};
@ -141,6 +159,8 @@ export function getLastModifiedField(entity: EntityType): string {
[EntityType.CONFIGURATION_ITEMS]: 'lastModifiedTime',
[EntityType.CONTACTS]: 'lastModifiedDate',
[EntityType.CONTRACTS]: 'lastModifiedDateTime',
[EntityType.CONTRACT_SERVICES]: 'lastModifiedDate',
[EntityType.AUTOTASK_SERVICES]: 'lastModifiedDate',
[EntityType.BILLING_ITEMS]: 'itemDate',
[EntityType.TIME_ENTRIES]: 'dateWorked',
[EntityType.TICKET_NOTES]: 'lastActivityDate',
@ -171,6 +191,8 @@ export function getActiveField(entity: EntityType): string | null {
[EntityType.CONFIGURATION_ITEMS]: 'isActive',
[EntityType.CONTACTS]: 'isActive',
[EntityType.CONTRACTS]: null, // Use status field instead
[EntityType.CONTRACT_SERVICES]: null,
[EntityType.AUTOTASK_SERVICES]: 'isActive',
[EntityType.BILLING_ITEMS]: null,
[EntityType.TIME_ENTRIES]: null, // Time entries don't have active status
[EntityType.TICKET_NOTES]: null, // Ticket notes don't have active status
@ -258,7 +280,9 @@ export function buildDateRangeFilter(
[EntityType.TIME_ENTRIES]: 'createDate', // TimeEntry uses createDate for filtering
[EntityType.PROJECTS]: 'startDateTime',
[EntityType.BILLING_ITEMS]: 'itemDate',
[EntityType.CONTRACTS]: 'startDate', // Contracts use startDate
[EntityType.CONTRACTS]: 'startDate',
[EntityType.CONTRACT_SERVICES]: null,
[EntityType.AUTOTASK_SERVICES]: null,
[EntityType.COMPANIES]: null,
[EntityType.RESOURCES]: null,
[EntityType.CONTACTS]: null,
@ -456,6 +480,8 @@ export function getEntityDisplayName(entity: EntityType): string {
[EntityType.CONFIGURATION_ITEMS]: 'Configuration Items',
[EntityType.CONTACTS]: 'Contacts',
[EntityType.CONTRACTS]: 'Contracts',
[EntityType.CONTRACT_SERVICES]: 'Contract Services',
[EntityType.AUTOTASK_SERVICES]: 'Autotask Services',
[EntityType.TIME_ENTRIES]: 'Time Entries',
[EntityType.TICKET_NOTES]: 'Ticket Notes',
};