---
phase: quick-260521-foj
plan: 01
subsystem: sync
tags:
  - autotask-sync
  - companies
  - tickets
  - foreign-key
  - bugfix
requirements:
  - QUICK-260521-FOJ
dependency_graph:
  requires:
    - lib/utils/sync-helpers.ts (existing build*Filter pattern)
    - lib/services/entity-sync.ts (existing full-sync filter chain + tickets validation pipeline)
    - lib/services/postgres-client.ts (singleton query interface)
  provides:
    - buildCompaniesFilter() (lib/utils/sync-helpers.ts)
    - EntitySyncService.getValidCompanyIds() (private)
    - Defensive ticket company_id nullification in syncEntity()
  affects:
    - Companies full sync (now fetches active + inactive)
    - Tickets full + weekly-full sync (no longer rolls back on tickets_company_id_fkey)
tech_stack:
  added: []
  patterns:
    - Mirror existing buildProjectPhasesFilter / buildContractServicesFilter "id > 0" template for any Autotask entity that requires a filter but where we want everything
    - Mirror existing getValidResourceIds + null-on-miss pattern for FK validation in syncEntity()
key_files:
  created: []
  modified:
    - lib/utils/sync-helpers.ts
    - lib/services/entity-sync.ts
decisions:
  - Widen Companies full sync to id > 0 (fetch all) rather than carrying isActive=true via buildActiveFilter, so inactive Autotask companies referenced by tickets are mirrored.
  - Add a belt-and-suspenders ticket company_id nullification block for the truly hard-deleted Autotask company case. Non-destructive — preserves the ticket row.
  - Did NOT touch getActiveField — still returns 'isActive' for COMPANIES because other entities and call sites use that mapping.
  - Did NOT touch syncTicketsChunked — out of scope; that path has its own flow and is not on the failing weekly-full path.
metrics:
  duration: 1m 49s
  completed_date: 2026-05-21
---

# Quick 260521-foj: Fix weekly-full FK error — widen Companies full sync Summary

One-liner: Widen Companies full sync to fetch active+inactive companies (id > 0) and defensively nullify ticket.company_id for hard-deleted-in-Autotask companies — eliminates the `tickets_company_id_fkey` rollback that has been failing every full and weekly-full sync since 2026-05-15.

## What changed

**`lib/utils/sync-helpers.ts`**
- Added new exported `buildCompaniesFilter()` returning `[{ field: 'id', op: 'gt', value: 0 }]`, mirroring the `buildProjectPhasesFilter` template. JSDoc explains the rationale (Companies endpoint requires a filter; active-only misses inactive companies referenced by tickets).

**`lib/services/entity-sync.ts`**
- Added `buildCompaniesFilter` to the named-imports block from `../utils/sync-helpers`.
- Inserted a new `COMPANIES` branch as the first branch in the full-sync filter chain (placed before `CONTRACTS`). It pushes the `id > 0` filter and logs `Full sync of all companies (active + inactive)`. This routes COMPANIES around the generic `else` block that would otherwise apply `buildActiveFilter` (`isActive=true`).
- Added a new private `getValidCompanyIds()` method that selects all `companies.id WHERE is_deleted = false`, mirroring `getValidResourceIds`.
- Added a new `if (entity === EntityType.TICKETS)` block between the existing `recordsWithoutCompany` filter and the existing resource-FK nullification block. It iterates `mappedRecords`, nullifies `ticket.company_id` when the referenced company is missing from the Pulse mirror, tracks up to 5 sample ticket IDs, and emits a `warn` log when the count is non-zero. Pattern matches the surrounding resource nullification block exactly.

## Why this fixes the FK error

Every full and weekly-full sync since 2026-05-15 was failing with `[DATABASE_CONSTRAINT_ERROR] ... tickets_company_id_fkey`, rolling back the tickets bulkUpsert transaction. Root cause: Companies full sync was applying `buildActiveFilter`, which sends `isActive=true` to the Autotask API. Companies marked inactive in Autotask but still referenced by open tickets were therefore not mirrored into Pulse. When the tickets sync ran next, the FK constraint on `tickets.company_id → companies.id` fired and rolled the whole batch back. Widening the Companies filter to `id > 0` closes the primary gap by fetching active+inactive companies. The defensive nullification covers the residual edge case where a company was hard-deleted from Autotask entirely — instead of rolling back, the ticket survives with `company_id = null` (the column is nullable, and the existing `recordsWithoutCompany` filter only drops tickets that have no `company_id` from the start, so the bulkUpsert path handles `null` cleanly via the resource-nullification precedent).

`hasAppliedFilters` stays true on Companies full sync because the new filter array is non-empty (`id > 0`), so `softDeleteMissingRecords` is NOT triggered — no accidental mass soft-delete of companies. Companies do not support incremental sync (per `supportsIncremental` at entity-sync.ts line 106), so this code path runs on every Companies sync (full, weekly-full, and the incremental→full fallback).

## Commits

| Task | Description                                                                          | Commit  | Files                                            |
| ---- | ------------------------------------------------------------------------------------ | ------- | ------------------------------------------------ |
| 1    | Add buildCompaniesFilter + wire COMPANIES into the full-sync filter chain            | 1ecaefe | lib/utils/sync-helpers.ts, lib/services/entity-sync.ts |
| 2    | Add getValidCompanyIds + defensive ticket company_id nullification                   | 62c529f | lib/services/entity-sync.ts                      |

## Verification

Type check passed cleanly:

```
cd /opt/stacks/pulse && npx tsc --noEmit --pretty
# (zero errors)
```

Targeted greps confirm shape:
- `grep -n "buildCompaniesFilter" lib/utils/sync-helpers.ts lib/services/entity-sync.ts` → 3 matches (definition + import + usage)
- `grep -c "getValidCompanyIds" lib/services/entity-sync.ts` → 2 (definition + call site)
- `grep -n "entity === EntityType.COMPANIES" lib/services/entity-sync.ts` → 1 new match (line 134, in the filter chain)
- `grep -n "if (entity === EntityType.TICKETS)" lib/services/entity-sync.ts` → 2 matches: line 261 (new company-validation block) BEFORE line 284 (existing resource-validation block), ordering correct.

## Manual verify step (user runs after merge)

```bash
curl -X POST http://localhost:3100/api/sync/full \
  -H 'content-type: application/json' \
  -d '{"triggeredBy":"manual-verify"}'
```

Then check `sync_history` for the next ticket sync row — `error_message` should no longer contain `tickets_company_id_fkey`, and `status` should be `completed`. Also sanity-check `SELECT count(*) FROM companies WHERE is_active = false AND is_deleted = false;` — the count should be ≥ the previous ~145 (or whatever the prior baseline was) once Companies full sync picks up inactive companies that weren't being fetched before.

## Out-of-scope (explicit non-changes)

- `getActiveField` in `lib/utils/sync-helpers.ts` — unchanged. Still returns `'isActive'` for COMPANIES (used by other call sites and for other entities like RESOURCES, CONTACTS, etc.).
- `buildActiveFilter` — unchanged.
- `syncTicketsChunked` — unchanged. The chunked path has its own (separate) flow and was not on the failing weekly-full path. Touching it was explicitly out of scope per the plan.
- The reconciliation service from `quick-260521-fci` — unchanged.
- `cachedValidResourceIds` / `cachedValidContactIds` — unchanged.
- The `lastActivityDate` filter on tickets — unchanged.
- Other branches in the full-sync filter chain (CONTRACTS, CONTRACT_SERVICES, PROJECTS, PROJECT_PHASES, TIME_ENTRIES, BILLING_ITEMS) — unchanged.

## Deviations from Plan

None — plan executed exactly as written.

## Known Stubs

None — both functions are wired end-to-end into the live sync path.

## Self-Check: PASSED

- `lib/utils/sync-helpers.ts` exists and contains `export function buildCompaniesFilter` at line 100.
- `lib/services/entity-sync.ts` exists; import (line 18), usage (line 135), COMPANIES branch (line 134), `getValidCompanyIds` method, and the new TICKETS company-validation block (line 261) all present.
- Commit `1ecaefe` exists in `git log` (Task 1).
- Commit `62c529f` exists in `git log` (Task 2).
- `npx tsc --noEmit --pretty` exits 0.
