Task Breakdown: Integrations — Twenty CRM
Feature ID: integrations-twenty-crm
Plan: ./plan.md
Status: Done (retrospective — surface already shipped)
Last updated: 2026-05-08
Phase 1 — Module + config (shipped)
- T1. Global Nest module at
apps/api/src/integrations/twenty-crm/twenty-crm.module.ts@Global()so any caller can inject services without re-importing.forRoot()andforRootAsync()factories.- Exports
TwentyCrmService,ClientService,CrmTenantService,CrmConfigService.
- T2.
CrmConfigServiceatapps/api/src/integrations/twenty-crm/config/crm-config.service.ts- Reads
TWENTY_CRM_BASE_URL,TWENTY_CRM_API_KEY,TWENTY_CRM_WORKSPACE_ID, plus the three numeric optionalsTWENTY_CRM_TIMEOUT_MS(30000),TWENTY_CRM_MAX_RETRIES(3),TWENTY_CRM_RETRY_DELAY_MS(1000). isEnabled/validateConfig(lists missing keys by env-var name).
- Reads
Phase 2 — Auth + config gate (shipped)
- T3.
CrmSyncGuardatapps/api/src/integrations/twenty-crm/guards/crm-sync.guard.ts- Returns
false+ warn whenisEnabled === false. - Returns
false+ error whenvalidateConfigthrows. - Returns
trueonly when both pass.
- Returns
- T4.
@CrmSync(enabled)decorator atapps/api/src/integrations/twenty-crm/decorators/crm-sync.decorator.ts- Stamps the
crm_syncNest metadata key. (Currently informational — see OQ-5 follow-up.)
- Stamps the
Phase 3 — Service layer (shipped)
- T5.
TwentyCrmServiceatapps/api/src/integrations/twenty-crm/services/twenty-crm.service.tsmakeRequest(method, endpoint, data?, params?, schema?)— builds URL via/restor/rest/metadata, sets bearer + workspace headers (with'default'workspace fallback), honourstimeout, wraps thrown axios errors asHttpExceptionwith the upstream message + details (or503for transport failures).
- T6.
ClientServiceatapps/api/src/integrations/twenty-crm/services/client.service.ts- 16 wrapper methods: 4 entities × {create, get, update, delete} plus 4 list helpers.
- Uses
PUTfor updates (see OQ-3).
- T7.
CrmTenantServiceatapps/api/src/integrations/twenty-crm/services/crm-tenant.service.tsresolveTenantContext(workId?, userId?, globalTenantId?)→{tenantId, workId, userId}with the three-level fallback.getTenantEndpointPrefix(ctx)→/tenants/<tenantId>.validateTenantContext/getTenantConfighelpers.
Phase 4 — Utils (shipped)
- T8.
RetryUtilsatapps/api/src/integrations/twenty-crm/utils/retry.utils.tswithRetryexponential-back-off helper, last-error re-throw, no-sleep onmaxAttempts === 1.isRetryableError— ECONNRESET / ETIMEDOUT / ENOTFOUND, 5xx, 429.calculateRetryDelay— exponential + 10% jitter, clamped atmaxDelayMs.
- T9.
MappingUtilsatapps/api/src/integrations/twenty-crm/utils/mapping.utils.ts- Four mappers (
mapClientToContact,mapCompanyToOrganization,mapItemToProduct,mapItemToDeal) and four validators (validateContactData,validateOrganizationData,validateProductData,validateDealData). - Currency default
'USD', deal stage'NEW', probability50. - URL-parse-with-
https://fallback fordomainName.
- Four mappers (
Phase 5 — Types (shipped)
- T10. Twenty wire-format types at
apps/api/src/integrations/twenty-crm/types/twenty-crm.types.tsTwentyContact,TwentyOrganization,TwentyProduct,TwentyDeal,TwentyCrmConfig,TwentyCrmResponse,TwentyCrmError,CrmSyncResult,CrmTenantContext.
- T11. EverWorks projection types at
apps/api/src/integrations/twenty-crm/types/mapping.types.tsEverWorksClient,EverWorksCompany,EverWorksItem,FieldMapping,EntityMapping,MappingResult.
Phase 6 — Controllers (shipped)
- T12.
CompaniesControlleratapps/api/src/integrations/twenty-crm/controllers/companies.service.ts- 4 endpoints (
GET,POST,PATCH /:id,DELETE /:id) under/api/twenty-crm/companies. - Class-level
@UseGuards(AuthSessionGuard).
- 4 endpoints (
- T13.
PeopleControlleratapps/api/src/integrations/twenty-crm/controllers/people.controler.ts- 4 endpoints with explicit field-projection on
POST. - NOT registered in
TwentyCrmModule.forRoot()— see OQ-2 follow-up T20.
- 4 endpoints with explicit field-projection on
Phase 7 — Tests (shipped via PR #498)
- T14. 107 unit tests covering:
RetryUtils(16) —withRetryexponential back-off,lastErrorpropagation, default args, no-sleep onmaxAttempts=1;isRetryableErrorECONNRESET / ETIMEDOUT / ENOTFOUND / 5xx / 429 retryable, 2xx / non-429 4xx not retryable;calculateRetryDelayexponential + jitter cap.MappingUtils(19) — multi-word vs single-token name split, empty-name fallback,mapCompanyToOrganizationURL /startsWith('http')host extraction + unparseable → undefined,mapItemToProduct/mapItemToDealUSD default- supplied-currency override + 50%-probability NEW-stage deal,
validate*Datahappy + missing-field paths.
- supplied-currency override + 50%-probability NEW-stage deal,
CrmConfigService(10) — env reads + 3 numeric defaults, explicit overrides,isEnabledtriple-AND,validateConfiglists each missing key.CrmSyncGuard(3) — disabled → false + warn (no validateConfig call), enabled + valid → true, enabled + throw → false + error log.CrmSyncdecorator (4) — default-true, explicit values, stablecrm_synckey.CrmTenantService(10) —work_<id>prefix, fallbacks, endpoint prefix, validateTenantContext, getTenantConfig preserves undefined.TwentyCrmService.makeRequest(8) —/rest<endpoint>URL,/rest/metadata<endpoint>whenschema=true, defaultX-Workspace-Id: default, body forwarding, HttpException pass-through with status + details, INTERNAL_SERVER_ERROR when status missing, SERVICE_UNAVAILABLE on no-response.ClientService(24) — table-driven across all 4 entities × 5 operations (create/get/update/delete/list).CompaniesController(5) +PeopleController(6) — thin-controller delegation incl. PeopleController explicit field-mapping that strips extraneous body keys and forwards undefined optional fields.
Phase 8 — Docs & retrospective
- T15. Spec, plan, and tasks authored under
docs/specs/features/integrations-twenty-crm/(this PR). - T16.
COVERAGE-TRACKER.mdrow moved to "Done" with this PR's link. - T17.
index.tsbarrel atapps/api/src/integrations/twenty-crm/index.tsexports the public surface for cross-module consumers.
Outstanding follow-ups
These map 1:1 to the open questions in spec.md §8. Each is its
own future PR.
- T18. Decide on / implement OQ-1: explicitly decorate
PeopleControllerwith@UseGuards(AuthSessionGuard)(or rely on a global guard). Today'sCompaniesControlleris explicitly guarded; symmetry helps prevent regressions if a future global-guard refactor changes defaults. - T19. OQ-2:
PeopleControlleris missing the@Controller('api/twenty-crm/people')decorator AND is not registered inTwentyCrmModule.forRoot(). Either fully wire it up (decorator + module registration + guard) or delete the file. Don't ship dead code. - T20. OQ-3:
@Patch(':id')controllers proxy toPUTservice methods. Switch to Twenty's PATCH semantics for partial updates, OR rename the controller methods to@Putso the wire shape matches expectations. - T21. OQ-4: align body-validation policy across
CompaniesController.createCompany(currently no whitelist) andPeopleController.createContact(whitelisted). Add aclass-validatorDTO for both:CreateCompanyDto,CreateContactDto,CreateDealDto,CreateProductDto. - T22. OQ-5: either remove
@CrmSync(no consumer reads its metadata) or wire it into a runtime gate (e.g. an interceptor that no-ops the request whencrm_syncisfalse). - T23. OQ-6: tighten
TwentyCrmModule.forRoot()typing —Partial<CrmConfigService>accepts inputs that crash on.twentyCrmConfig.timeout. Replace with aTwentyCrmConfigtype narrowed to the legitimate optionals. - T24. OQ-7: decide between
RetryUtilsstatic-class vs pure-function helpers. If functions are preferred, orchestrate a one-shot rename across the integration. - T25. OQ-8: wrap
TwentyCrmService.makeRequestinRetryUtils.withRetryby default, gated onRetryUtils.isRetryableError. Today only callers who explicitly opt in get retries. - T26. OQ-9: actually pass
CrmTenantServiceprefixes toTwentyCrmService.makeRequestso per-work tenant isolation becomes more than a theoretical hook. Today every controller hits the shared workspace, so the tenant resolver is unused at the wire level. - T27. e2e test against
nock-mocked Twenty CRM that drives the controllers end-to-end (current coverage is unit-level only). - T28. Consider migrating the integration to
packages/plugins/twenty-crmso other deployments can swap in HubSpot / Salesforce / Pipedrive plugins without touchingapps/api. (Principle I.) - T29. Audit: confirm
CompaniesController.createCompanydoesn't acceptidin the body (would clash with Twenty-server-generated ids).
Definition of Done
- All checkboxes T1–T17 ticked. ✅ (this is a retrospective spec)
- 107 unit tests in PR #498 passing.
pnpm format:check,pnpm lint, andpnpm --filter ever-works-api testgreen at PR-merge time.- Outstanding follow-ups T18–T29 captured above; none are blocking.