feat: Veeam RPO analysis, comparison, ticket analysis + company teams table

- Add Veeam RPO analysis page (/veeam-analysis) and comparison page (/veeam-comparison)
- Add API routes: /api/veeam/rpo-analyze, rpo-comparison, rpo-offline-log, ticket-analysis
- Add veeam-rpo-service.ts enhancements (RPO logic, offline detection, comparison)
- Add veeam-analysis-state.ts and rmm-device-resolver.ts services
- Add migrations 065-068: company_teams, veeam_rpo_offline_log, rpo_comparison_tables, veeam_ticket_analysis
- Add backup-status page updates and nav links for new Veeam pages
- Add scripts: deactivate-cis-for-inactive-companies, workstation category updates
- Add docs: mimecast-api-guide, veeam-backup-alerting-recommendation, workstation-backup-overview, ticket-analyzer-prompt
- Minor: webhook-service, entity-sync, entity-mapper, sync-helpers, sync.ts, middleware.ts updates
This commit is contained in:
lorentz 2026-04-29 09:16:46 -04:00
parent 07067bef19
commit ea3471d38d
36 changed files with 5604 additions and 217 deletions

View file

@ -103,7 +103,7 @@ export class EntitySyncService {
// For incremental sync, filter by last sync time
// Note: Companies and Resources don't support date-based filtering in Autotask API
const supportsIncremental = entity !== EntityType.COMPANIES && entity !== EntityType.RESOURCES;
const supportsIncremental = entity !== EntityType.COMPANIES && entity !== EntityType.RESOURCES && entity !== EntityType.COMPANY_TEAMS;
if (isIncremental && supportsIncremental) {
try {
@ -415,6 +415,41 @@ export class EntitySyncService {
}
}
// After contacts sync, backfill primary_contact_id and billing_contact_id on companies
if (entity === EntityType.CONTACTS) {
try {
const primaryResult = await postgresClient.query(
`UPDATE companies c
SET primary_contact_id = (
SELECT id FROM contacts
WHERE company_id = c.id AND primary_contact = true AND is_deleted = false
ORDER BY id LIMIT 1
)
WHERE EXISTS (
SELECT 1 FROM contacts
WHERE company_id = c.id AND primary_contact = true AND is_deleted = false
)`
);
entityLogger.info('Backfilled companies.primary_contact_id', { updatedCount: (primaryResult as any).rowCount ?? 0 });
const billingResult = await postgresClient.query(
`UPDATE companies c
SET billing_contact_id = (
SELECT id FROM contacts
WHERE company_id = c.id AND billing_contact = true AND is_deleted = false
ORDER BY id LIMIT 1
)
WHERE EXISTS (
SELECT 1 FROM contacts
WHERE company_id = c.id AND billing_contact = true AND is_deleted = false
)`
);
entityLogger.info('Backfilled companies.billing_contact_id', { updatedCount: (billingResult as any).rowCount ?? 0 });
} catch (err) {
entityLogger.warn('Contact FK backfill on companies failed', { error: String(err) });
}
}
// 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.
@ -817,6 +852,13 @@ export class EntitySyncService {
return await this.syncEntity(EntityType.CONTACTS, isIncremental);
}
/**
* Sync Company Teams (TAMs, CSMs, co-managed resources assigned to a company)
*/
async syncCompanyTeams(isIncremental: boolean = false): Promise<EntitySyncStats> {
return await this.syncEntity(EntityType.COMPANY_TEAMS, isIncremental);
}
/**
* Sync Contracts
*/

View file

@ -0,0 +1,90 @@
/**
* RMM Device Resolver
* Matches Veeam backup agent jobs to their Datto RMM device via:
* veeam_organizations.company_id datto_rmm_sites.autotask_company_id (org match)
* veeam_backup_agents.name datto_rmm_devices.hostname (hostname match)
*
* Scoped to Desktop/Laptop device categories only server jobs are handled separately.
* Reusable by any service that needs to correlate Veeam jobs with RMM device state.
*/
import postgresClient from './postgres-client';
export interface RmmDeviceInfo {
hostname: string;
site_name: string;
device_type_category: string;
last_seen: Date | null;
online: boolean;
}
/**
* Bulk-resolve Veeam backup agent jobs to their matching Datto RMM devices.
* Returns a Map keyed by job instance_uid. Jobs with no RMM match are absent.
*/
export async function resolveRmmDevicesForJobs(
jobInstanceUids: string[]
): Promise<Map<string, RmmDeviceInfo>> {
if (jobInstanceUids.length === 0) return new Map();
// datto_rmm_sites.autotask_company_id is not reliably populated from the API,
// so join via companies.company_name = datto_rmm_sites.name instead.
// Datto sites are often named "Company - Location" while company_name is "Company",
// so match exact OR site name starts with "company - ".
// DISTINCT ON (job) prevents fanout when a company has multiple matching sites,
// preferring the device with the most recent last_seen.
const res = await postgresClient.query(`
SELECT DISTINCT ON (j.instance_uid)
j.instance_uid AS job_instance_uid,
rmm.hostname,
rs.name AS site_name,
rmm.device_type_category,
rmm.last_seen,
rmm.online
FROM veeam_backup_agent_jobs j
JOIN veeam_backup_agents ba ON ba.instance_uid = j.backup_agent_uid
JOIN veeam_organizations vo ON vo.instance_uid = j.organization_uid
JOIN companies c ON c.id = vo.company_id
JOIN datto_rmm_sites rs ON LOWER(rs.name) = LOWER(c.company_name)
OR LOWER(rs.name) LIKE LOWER(c.company_name) || ' - %'
JOIN datto_rmm_devices rmm
ON rmm.site_id = rs.id
AND LOWER(rmm.hostname) = LOWER(ba.name)
AND rmm.device_type_category IN ('Desktop', 'Laptop')
AND rmm.deleted = false
WHERE j.instance_uid = ANY($1)
ORDER BY j.instance_uid, rmm.last_seen DESC NULLS LAST
`, [jobInstanceUids]);
const map = new Map<string, RmmDeviceInfo>();
for (const row of res.rows) {
map.set(row.job_instance_uid, {
hostname: row.hostname,
site_name: row.site_name,
device_type_category: row.device_type_category,
last_seen: row.last_seen ? new Date(row.last_seen) : null,
online: row.online,
});
}
return map;
}
/**
* Returns true when the device should suppress RPO alerting.
* A device is considered suppressed if it has been offline longer than one full
* backup interval meaning it was already offline when the backup was due to run.
* No last_seen not suppressed (device exists in RMM but has never reported; alert normally).
*/
export function isDeviceOfflineSuppressed(info: RmmDeviceInfo, intervalHours: number): boolean {
if (!info.last_seen) return false;
const hoursOffline = (Date.now() - info.last_seen.getTime()) / 3_600_000;
return hoursOffline > intervalHours;
}
/**
* Returns hours since the device was last seen by RMM, or null if never seen.
*/
export function hoursOffline(info: RmmDeviceInfo): number | null {
if (!info.last_seen) return null;
return (Date.now() - info.last_seen.getTime()) / 3_600_000;
}

View file

@ -0,0 +1,9 @@
// Shared in-process state for the background ticket analysis runner.
// Works because we run in Docker (long-lived Node process), not serverless.
export const analysisState = {
isRunning: false,
total: 0,
done: 0,
errors: 0,
startedAt: null as Date | null,
};

View file

@ -6,6 +6,12 @@
import postgresClient from './postgres-client';
import { AutotaskClient } from './autotask-client';
import {
RmmDeviceInfo,
resolveRmmDevicesForJobs,
isDeviceOfflineSuppressed,
hoursOffline,
} from './rmm-device-resolver';
const AT_QUEUE_ID = 29832283; // Operations Triage
const AT_ISSUE_TYPE = 38; // Backups
@ -16,12 +22,17 @@ const AT_PRIORITY_CRIT = 1; // Critical
const AT_STATUS_NEW = 1;
const AT_STATUS_DONE = 5;
// Shadow mode: log what the service *would* do without touching Autotask.
// Default true — set VEEAM_RPO_SHADOW_MODE=false in env to go live.
const SHADOW_MODE = process.env.VEEAM_RPO_SHADOW_MODE !== 'false';
export interface RpoCheckResult {
checked: number;
newTickets: number;
escalated: number;
resolved: number;
skipped: number;
offlineSuppressed: number;
errors: string[];
runAt: Date;
}
@ -36,8 +47,12 @@ export interface RpoJobSummary {
hours_since_backup: number | null;
rpo_hours: number;
is_breached: boolean;
is_offline_suppressed: boolean;
failure_category: string | null;
failure_message: string | null;
rmm_hostname: string | null;
rmm_site_name: string | null;
rmm_last_seen: string | null;
open_ticket: {
at_ticket_id: number;
at_ticket_number: string;
@ -138,11 +153,12 @@ export class VeeamRpoService {
escalated: 0,
resolved: 0,
skipped: 0,
offlineSuppressed: 0,
errors: [],
runAt: new Date(),
};
const client = getAutotaskClient();
const client = SHADOW_MODE ? null : getAutotaskClient();
// Fetch all enabled workstation jobs with org info
const jobsRes = await postgresClient.query(`
@ -169,27 +185,32 @@ export class VeeamRpoService {
const jobs = jobsRes.rows;
result.checked = jobs.length;
// Fetch all currently open RPO tickets in one query
const openTicketsRes = await postgresClient.query(`
SELECT * FROM veeam_rpo_tickets WHERE resolved_at IS NULL
`);
// Fetch all currently open tracking tickets (shadow or live table)
const trackingTable = SHADOW_MODE ? 'veeam_rpo_shadow_tickets' : 'veeam_rpo_tickets';
const openTicketsRes = await postgresClient.query(
`SELECT * FROM ${trackingTable} WHERE resolved_at IS NULL`
);
const openByJobUid: Record<string, any> = {};
for (const row of openTicketsRes.rows) {
openByJobUid[row.job_instance_uid] = row;
}
// Bulk-resolve RMM devices for all jobs in a single query
const rmmMap = await resolveRmmDevicesForJobs(jobs.map((j: any) => j.instance_uid));
for (const job of jobs) {
try {
await this.processJob(job, openByJobUid, client, result);
const rmmDevice = rmmMap.get(job.instance_uid) ?? null;
await this.processJob(job, openByJobUid, rmmDevice, client as AutotaskClient, result);
} catch (err: any) {
result.errors.push(`${job.job_name}: ${err.message}`);
}
}
// Update last_checked_at for all processed jobs
await postgresClient.query(`
UPDATE veeam_rpo_tickets SET last_checked_at = NOW() WHERE resolved_at IS NULL
`);
// Update last_checked_at for all open tracking tickets
await postgresClient.query(
`UPDATE ${trackingTable} SET last_checked_at = NOW() WHERE resolved_at IS NULL`
);
return result;
}
@ -197,6 +218,7 @@ export class VeeamRpoService {
private async processJob(
job: any,
openByJobUid: Record<string, any>,
rmmDevice: RmmDeviceInfo | null,
client: AutotaskClient,
result: RpoCheckResult,
): Promise<void> {
@ -214,6 +236,24 @@ export class VeeamRpoService {
: (job.schedule_type ?? '').toLowerCase().includes('continuous') ? 1
: 24;
// Suppress new tickets and escalations for Desktop/Laptop devices that have been
// offline longer than one backup interval — the machine was offline before the backup
// was due, so a missed backup is expected. Existing open tickets are left untouched,
// but we backfill rmm_hostname so the comparison page can match them correctly.
if (rmmDevice && isDeviceOfflineSuppressed(rmmDevice, intervalHours)) {
const openTicket = openByJobUid[job.instance_uid] ?? null;
if (openTicket && !openTicket.rmm_hostname) {
const trackingTable = SHADOW_MODE ? 'veeam_rpo_shadow_tickets' : 'veeam_rpo_tickets';
await postgresClient.query(
`UPDATE ${trackingTable} SET rmm_hostname = $1, updated_at = NOW() WHERE job_instance_uid = $2`,
[rmmDevice.hostname, job.instance_uid]
);
}
await this.logOfflineSuppression(job, rmmDevice, intervalHours);
result.offlineSuppressed++;
return;
}
// Breach rules:
// - Failed/Warning: always breached — a failed backup is a failed backup regardless of recency
// - None (never run): always breached
@ -226,8 +266,12 @@ export class VeeamRpoService {
if (!isBreached) {
if (openTicket) {
await this.resolveTicket(openTicket, client);
result.resolved++;
if (SHADOW_MODE) {
await this.shadowResolve(openTicket, job, rmmDevice, result);
} else {
await this.resolveTicket(openTicket, client!);
result.resolved++;
}
} else {
result.skipped++;
}
@ -248,15 +292,22 @@ export class VeeamRpoService {
if (!openTicket) {
if (tooOldForNewTicket) {
if (SHADOW_MODE) await this.writeShadowLog(job, rmmDevice, 'would_skip_too_old', null, null, null);
result.skipped++;
return;
}
// Create new ticket
await this.createTicket(job, hoursOverdue, failureCategory, targetPriority, client, result);
if (SHADOW_MODE) {
await this.shadowCreate(job, rmmDevice, hoursOverdue, failureCategory, targetPriority, result);
} else {
await this.createTicket(job, hoursOverdue, failureCategory, targetPriority, client!, result);
}
} else {
// Escalate if needed
if (openTicket.priority_level !== targetPriority && this.isPriorityHigher(targetPriority, openTicket.priority_level)) {
await this.escalateTicket(openTicket, job, hoursOverdue, failureCategory, targetPriority, client, result);
if (SHADOW_MODE) {
await this.shadowEscalate(openTicket, job, rmmDevice, hoursOverdue, failureCategory, targetPriority, result);
} else {
await this.escalateTicket(openTicket, job, hoursOverdue, failureCategory, targetPriority, client!, result);
}
} else {
result.skipped++;
}
@ -268,6 +319,130 @@ export class VeeamRpoService {
return (rank[a] ?? 0) > (rank[b] ?? 0);
}
// ─── Shadow mode methods ───────────────────────────────────────────────────
private async writeShadowLog(
job: any,
rmmDevice: RmmDeviceInfo | null,
action: string,
priorityLevel: string | null,
hoursOverdue: number | null,
failureCategory: string | null,
): Promise<void> {
await postgresClient.query(`
INSERT INTO veeam_rpo_shadow_log
(job_instance_uid, job_name, org_name, rmm_hostname, rmm_site_name,
action, priority_level, hours_overdue, failure_category, checked_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,NOW())
`, [
job.instance_uid, job.job_name, job.org_name,
rmmDevice?.hostname ?? null, rmmDevice?.site_name ?? null,
action, priorityLevel,
hoursOverdue !== null ? Math.round(hoursOverdue) : null,
failureCategory,
]);
}
private async shadowCreate(
job: any,
rmmDevice: RmmDeviceInfo | null,
hoursOverdue: number,
failureCategory: string,
priorityLevel: string,
result: RpoCheckResult,
): Promise<void> {
await postgresClient.query(`
INSERT INTO veeam_rpo_shadow_tickets
(job_instance_uid, job_name, org_name, rmm_hostname, priority_level,
hours_overdue, failure_category, opened_at, last_checked_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,NOW(),NOW())
ON CONFLICT (job_instance_uid) DO UPDATE SET
priority_level = EXCLUDED.priority_level,
hours_overdue = EXCLUDED.hours_overdue,
failure_category = EXCLUDED.failure_category,
rmm_hostname = COALESCE(EXCLUDED.rmm_hostname, veeam_rpo_shadow_tickets.rmm_hostname),
resolved_at = NULL,
opened_at = NOW(),
last_checked_at = NOW(),
updated_at = NOW()
`, [
job.instance_uid, job.job_name, job.org_name,
rmmDevice?.hostname ?? null, priorityLevel,
Math.round(hoursOverdue), failureCategory,
]);
await this.writeShadowLog(job, rmmDevice, 'would_create', priorityLevel, hoursOverdue, failureCategory);
result.newTickets++;
console.log(`[RPO-SHADOW] Would create ticket: ${job.job_name} @ ${job.org_name} (${Math.round(hoursOverdue)}h overdue, ${priorityLevel})`);
}
private async shadowEscalate(
openTicket: any,
job: any,
rmmDevice: RmmDeviceInfo | null,
hoursOverdue: number,
failureCategory: string,
targetPriority: string,
result: RpoCheckResult,
): Promise<void> {
await postgresClient.query(`
UPDATE veeam_rpo_shadow_tickets SET
priority_level = $1,
hours_overdue = $2,
failure_category = $3,
last_checked_at = NOW(),
updated_at = NOW()
WHERE job_instance_uid = $4
`, [targetPriority, Math.round(hoursOverdue), failureCategory, job.instance_uid]);
await this.writeShadowLog(job, rmmDevice, 'would_escalate', targetPriority, hoursOverdue, failureCategory);
result.escalated++;
console.log(`[RPO-SHADOW] Would escalate to ${targetPriority}: ${job.job_name} (${Math.round(hoursOverdue)}h overdue)`);
}
private async shadowResolve(
openTicket: any,
job: any,
rmmDevice: RmmDeviceInfo | null,
result: RpoCheckResult,
): Promise<void> {
await postgresClient.query(`
UPDATE veeam_rpo_shadow_tickets SET
resolved_at = NOW(),
last_checked_at = NOW(),
updated_at = NOW()
WHERE job_instance_uid = $1
`, [openTicket.job_instance_uid]);
await this.writeShadowLog(job, rmmDevice, 'would_resolve', null, null, null);
result.resolved++;
console.log(`[RPO-SHADOW] Would resolve: ${job.job_name} — backup succeeded`);
}
// ──────────────────────────────────────────────────────────────────────────
private async logOfflineSuppression(
job: any,
rmmDevice: RmmDeviceInfo,
intervalHours: number,
): Promise<void> {
const hrs = hoursOffline(rmmDevice) ?? 0;
await postgresClient.query(`
INSERT INTO veeam_rpo_offline_log
(job_instance_uid, job_name, org_name, rmm_hostname, rmm_site_name,
device_type_category, rmm_last_seen, hours_offline, backup_interval_hours, checked_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
`, [
job.instance_uid,
job.job_name,
job.org_name,
rmmDevice.hostname,
rmmDevice.site_name,
rmmDevice.device_type_category,
rmmDevice.last_seen,
Math.round(hrs * 100) / 100,
intervalHours,
]);
console.log(`[RPO] Suppressed (offline ${Math.round(hrs)}h): ${job.job_name} @ ${rmmDevice.hostname}`);
}
private async createTicket(
job: any,
hoursOverdue: number,
@ -289,9 +464,8 @@ export class VeeamRpoService {
// Look up Autotask company ID from org name mapping
const companyRes = await postgresClient.query(`
SELECT c.id FROM companies c
JOIN veeam_organizations vo ON vo.autotask_company_id = c.id
WHERE vo.name = $1
SELECT company_id AS id FROM veeam_organizations
WHERE name = $1
LIMIT 1
`, [job.org_name]);
@ -303,7 +477,6 @@ export class VeeamRpoService {
status: AT_STATUS_NEW,
queueID: AT_QUEUE_ID,
issueType: AT_ISSUE_TYPE,
subIssueType: AT_SUB_ISSUE,
priority: atPriority,
};
if (companyId) ticketPayload.companyID = companyId;
@ -398,7 +571,7 @@ export class VeeamRpoService {
console.log(`[RPO] Resolved ticket ${openTicket.at_ticket_number} — backup succeeded`);
}
async getStatus(): Promise<{ summary: Record<string, number>; jobs: RpoJobSummary[] }> {
async getStatus(): Promise<{ shadowMode: boolean; summary: Record<string, number>; jobs: RpoJobSummary[] }> {
const jobsRes = await postgresClient.query(`
SELECT
j.instance_uid,
@ -411,7 +584,12 @@ export class VeeamRpoService {
j.failure_message,
o.name as org_name,
EXTRACT(EPOCH FROM (NOW() - j.last_end_time)) / 3600.0 as hours_since_backup,
EXTRACT(EPOCH FROM (j.next_run - NOW())) / 3600.0 as hours_until_next_run
EXTRACT(EPOCH FROM (j.next_run - NOW())) / 3600.0 as hours_until_next_run,
rt.at_ticket_id,
rt.at_ticket_number,
rt.priority_level,
rt.hours_overdue,
rt.opened_at
FROM veeam_backup_agent_jobs j
JOIN veeam_organizations o ON o.instance_uid = j.organization_uid
LEFT JOIN veeam_rpo_tickets rt
@ -421,7 +599,10 @@ export class VeeamRpoService {
ORDER BY hours_since_backup DESC NULLS LAST
`);
const jobs: RpoJobSummary[] = jobsRes.rows.map((row) => {
const rows = jobsRes.rows;
const rmmMap = await resolveRmmDevicesForJobs(rows.map((r: any) => r.instance_uid));
const jobs: RpoJobSummary[] = rows.map((row: any) => {
const thresholds = getRpoThresholds(row.schedule_type);
const hoursAgo: number | null = row.hours_since_backup !== null ? parseFloat(row.hours_since_backup) : null;
const intervalHours = (row.schedule_type ?? '').toLowerCase().includes('weekly') ? 168
@ -432,39 +613,52 @@ export class VeeamRpoService {
&& (row.status === 'Failed' || row.status === 'Warning'
|| hoursAgo === null
|| hoursAgo > rpoWindowHours);
const rmmDevice = rmmMap.get(row.instance_uid) ?? null;
const isOfflineSuppressed = isBreached
&& rmmDevice !== null
&& isDeviceOfflineSuppressed(rmmDevice, intervalHours);
return {
job_instance_uid: row.instance_uid,
job_name: row.job_name,
org_name: row.org_name,
status: row.status,
schedule_type: row.schedule_type,
last_end_time: row.last_end_time,
hours_since_backup: hoursAgo !== null ? Math.round(hoursAgo * 10) / 10 : null,
rpo_hours: thresholds.grace,
is_breached: isBreached,
failure_category: row.failure_message ? categorizeFailure(row.failure_message) : null,
failure_message: row.failure_message,
job_instance_uid: row.instance_uid,
job_name: row.job_name,
org_name: row.org_name,
status: row.status,
schedule_type: row.schedule_type,
last_end_time: row.last_end_time,
hours_since_backup: hoursAgo !== null ? Math.round(hoursAgo * 10) / 10 : null,
rpo_hours: thresholds.grace,
is_breached: isBreached,
is_offline_suppressed: isOfflineSuppressed,
failure_category: row.failure_message ? categorizeFailure(row.failure_message) : null,
failure_message: row.failure_message,
rmm_hostname: rmmDevice?.hostname ?? null,
rmm_site_name: rmmDevice?.site_name ?? null,
rmm_last_seen: rmmDevice?.last_seen?.toISOString() ?? null,
open_ticket: row.at_ticket_id ? {
at_ticket_id: (row as any).at_ticket_id,
at_ticket_number: (row as any).at_ticket_number,
priority_level: (row as any).priority_level,
hours_overdue: (row as any).hours_overdue,
opened_at: (row as any).opened_at,
at_ticket_id: row.at_ticket_id,
at_ticket_number: row.at_ticket_number,
priority_level: row.priority_level,
hours_overdue: row.hours_overdue,
opened_at: row.opened_at,
} : null,
};
});
const breached = jobs.filter(j => j.is_breached).length;
const withTicket = jobs.filter(j => j.open_ticket !== null).length;
const healthy = jobs.filter(j => !j.is_breached).length;
const critical = jobs.filter(j => j.open_ticket?.priority_level === 'critical').length;
const high = jobs.filter(j => j.open_ticket?.priority_level === 'high').length;
const breached = jobs.filter(j => j.is_breached).length;
const offlineSuppressed = jobs.filter(j => j.is_offline_suppressed).length;
const withTicket = jobs.filter(j => j.open_ticket !== null).length;
const healthy = jobs.filter(j => !j.is_breached).length;
const critical = jobs.filter(j => j.open_ticket?.priority_level === 'critical').length;
const high = jobs.filter(j => j.open_ticket?.priority_level === 'high').length;
return {
shadowMode: SHADOW_MODE,
summary: {
total: jobs.length,
healthy,
breached,
offlineSuppressed,
withOpenTicket: withTicket,
critical,
high,

View file

@ -116,6 +116,7 @@ export class WebhookService {
);
}
return {
success: true,
eventId: payload.eventId,

View file

@ -30,6 +30,7 @@ export enum EntityType {
PROJECT_PHASES = 'project_phases',
COMPANY_CATEGORIES = 'company_categories',
COMPANY_TYPES = 'company_types',
COMPANY_TEAMS = 'company_teams',
}
// Sync operation types
@ -181,6 +182,7 @@ export const ENTITY_DEPENDENCIES: Record<EntityType, EntityType[]> = {
[EntityType.PROJECT_PHASES]: [EntityType.PROJECTS], // Depends on projects
[EntityType.COMPANY_CATEGORIES]: [], // No dependencies — standalone lookup
[EntityType.COMPANY_TYPES]: [], // No dependencies — standalone lookup
[EntityType.COMPANY_TEAMS]: [EntityType.COMPANIES, EntityType.RESOURCES], // Depends on companies and resources
};
// Autotask API field names (for incremental sync)

View file

@ -83,6 +83,9 @@ export function mapAutotaskToDatabase(
case EntityType.TAGS:
mapped = mapTag(data);
break;
case EntityType.COMPANY_TEAMS:
mapped = mapCompanyTeam(data);
break;
default:
// Fallback: auto-convert camelCase to snake_case
mapped = {};
@ -826,6 +829,19 @@ function mapPicklist(data: any): Record<string, any> {
};
}
/**
* Map CompanyTeam entity
*/
function mapCompanyTeam(data: any): Record<string, any> {
return {
id: data.id,
company_id: data.companyID,
resource_id: data.resourceID,
is_associated_as_comanaged: data.isAssociatedAsComanaged || false,
is_deleted: false,
};
}
/**
* Batch map multiple entities
*/

View file

@ -72,6 +72,7 @@ export function getAllEntitiesInOrder(): EntityType[] {
EntityType.TIME_ENTRIES,
EntityType.TAG_GROUPS,
EntityType.TAGS,
EntityType.COMPANY_TEAMS,
]);
}
@ -130,6 +131,7 @@ export function getAutotaskEntityName(entity: EntityType): string {
[EntityType.PROJECT_PHASES]: 'Phases',
[EntityType.COMPANY_CATEGORIES]: 'CompanyCategories',
[EntityType.COMPANY_TYPES]: 'CompanyTypes',
[EntityType.COMPANY_TEAMS]: 'CompanyTeams',
};
return mapping[entity] || entity;
@ -185,6 +187,7 @@ export function getLastModifiedField(entity: EntityType): string {
[EntityType.PROJECT_PHASES]: 'lastActivityDateTime',
[EntityType.COMPANY_CATEGORIES]: 'lastModifiedDate',
[EntityType.COMPANY_TYPES]: 'lastModifiedDate',
[EntityType.COMPANY_TEAMS]: 'lastModifiedDate', // No date field; incremental not supported
};
return mapping[entity] || 'lastModifiedDate';
@ -222,6 +225,7 @@ export function getActiveField(entity: EntityType): string | null {
[EntityType.PROJECT_PHASES]: null, // No active field on phases
[EntityType.COMPANY_CATEGORIES]: 'isActive',
[EntityType.COMPANY_TYPES]: 'isActive',
[EntityType.COMPANY_TEAMS]: null,
};
return mapping[entity] || null;
@ -319,6 +323,7 @@ export function buildDateRangeFilter(
[EntityType.PROJECT_PHASES]: null,
[EntityType.COMPANY_CATEGORIES]: null,
[EntityType.COMPANY_TYPES]: null,
[EntityType.COMPANY_TEAMS]: null,
};
const dateField = dateFieldMapping[entity];
@ -529,6 +534,7 @@ export function getEntityDisplayName(entity: EntityType): string {
[EntityType.PROJECT_PHASES]: 'Project Phases',
[EntityType.COMPANY_CATEGORIES]: 'Company Categories',
[EntityType.COMPANY_TYPES]: 'Company Types',
[EntityType.COMPANY_TEAMS]: 'Company Teams',
};
return mapping[entity] || entity;