Maximizer → Twenty CRM Migration
Section titled “Maximizer → Twenty CRM Migration”Full migration of Maximizer (legacy desktop CRM, flagged as an unsupported business risk in Tech Stack.md) to a self-hosted Twenty CRM instance. Spun off from Fable-usage — Talbot’s Fable 5 window was used for planning/schema-reverse-engineering; implementation ran on Sonnet 5 per the plan’s own recommendation.
What shipped
Section titled “What shipped”- Self-hosted Twenty CRM — Docker Compose (
~/utils/crm-20/twenty/), reachable athttp://localhost:3000, Windows Desktop shortcut + launcher script for one-click access - Full data migration: 18,586 people (deduplicated from 18,774), 45 companies, 34,998 notes — all with correct historical dates on both the Notes tab and Timeline tab
- CASL-critical fields (
emailStatus/optInDate/optOutDate) verified intact - 4 saved Views: Media Contacts, Advisors, Clients, Prospects - OptedIn
- Nightly Postgres backup via
my_backup(BackupCRM20.py) →d:\FSS\Misc\CRM-20\Bak\twenty-postgres.sql.gz - CRM-20 ongoing project registered and scaffolded (
~/utils/crm-20/; ETL toolmax2twenty/kept, re-runnable/checkpoint-safe) - Open follow-on filed separately: CRM-20-Accessibility (remote access — Cloudflare Tunnel vs. Tailscale, Talbot’s decision pending)
Key discoveries (full detail in ~/utils/crm-20/LESSONS.md)
Section titled “Key discoveries (full detail in ~/utils/crm-20/LESSONS.md)”- Twenty’s Core API and Metadata API use completely different mutation conventions (
createPerson(data:...)vscreateOneField(input:{field:{...}})) — cost real time reverse-engineering via the compiled server JS since GraphQL introspection is disabled for unauthenticated requests. load.pynever setcreatedAton any note — every one of 34,998 notes got migration load-time as its date instead of its true Maximizer date. Root cause of the whole “Timeline looks wrong” thread. Fixed non-destructively (createdAtis writable viaupdateNote) rather than requiring a delete+reload.- Twenty tracks note history in two places:
Note.createdAt(the record itself) and a separateTimelineActivityaudit-log object (its owncreatedAt, joined vialinkedRecordId) — the Timeline tab reads the latter. Fixing only the Note wasn’t sufficient; both needed correcting. - Maximizer’s bulk-imported historical notes carry two date levels — a bulk-import timestamp in
DateCol/TimeCol, and the real date embedded in the note text itself ("LOGS prior to 2014AP ... 11/16/13: ..."or Talbot’s ownDDMMMYYshorthand). ~18.5% of notes needed this embedded-date extraction. - Twenty’s native person-merge endpoint cascade-deletes the losing record’s notes without re-pointing them first — caught after 5 test merges by comparing raw Postgres table counts (the API response alone didn’t reveal it). Fixed by re-pointing
noteTargets to the survivor before calling merge, not after. - Saved Views live on a third API surface (
/rest/metadata/views+/rest/metadata/viewFilters, plain REST, not GraphQL).
Reusable practice worth considering for global AGENTS.md (surfaced, not yet promoted)
Section titled “Reusable practice worth considering for global AGENTS.md (surfaced, not yet promoted)”Before/after a mass update or merge on live production data, verify raw row counts in the database directly — not just the API’s response shape. The merge-endpoint bug above would have silently orphaned notes across all 194 duplicate pairs if the first 5 hadn’t been checked against Postgres directly; the GraphQL response looked complete and correct in every case. Talbot: flag if you want this added to the global rules.
Numbers
Section titled “Numbers”| Metric | Count |
|---|---|
| People (post-dedupe) | 18,586 |
| Companies | 45 |
| Notes | 34,998 |
| Duplicate pairs merged | 194 (5 test + 189 batch) |
| Notes with embedded-date correction | 6,477 |
| Notes with load-time→original-date correction | 28,350 |
| TimelineActivity records corrected | 34,985 |
| Email data-quality exceptions (unfixable typos) | 148 |
Full task history (multi-day back-and-forth): Tasks/archive/2026-07/Maximizer-Twenty-CRM-Migration.md