Dentolize · CRM Module Walkthrough
On this pageBusiness viewTechnical view

Security, Flags & Data Retention

Business view

The CRM handles two sensitive things: platform access tokens (which can spend a clinic's ad budget and read their messages) and personal data in webhook payloads (names, phone numbers, message text). The module was built with that in mind.

  • Tokens are encrypted at rest and are never returned to any screen or API response.
  • Personal data in the raw webhook log is automatically redacted after 90 days and

deleted after a year — a privacy-retention safeguard, not just a cleanup job.

  • Every capability is double-gated: a clinic must both have the feature switched on and

the individual staff member must hold the right permission.

  • Tenant isolation is never guessed. When a webhook arrives, Dentolize figures out which

clinic it belongs to from its own stored mappings — never from data the sender controls.

Technical view

The token vault (AES-256-GCM)

packages/utilities/src/cryptoVault/index.ts implements authenticated encryption: aes-256-gcm, 12-byte random IV, 32-byte key (:17). The wire format is versioned — v<keyId>:<iv>:<authTag>:<ciphertext> (:7, validated by a FORMAT_REGEX, :20) — which makes key rotation possible: new writes use the current key (SOCIAL_TOKEN_ENC_KEY + SOCIAL_TOKEN_ENC_KEY_ID), while reads select the key by the id embedded in the ciphertext, so retired keys (SOCIAL_TOKEN_ENC_KEYS_OLD) can still decrypt. Migration helpers readSecret / rotateSecret (:97, :109) dual-read plaintext-or- encrypted columns and re-encrypt only when a value was written with a retired/plaintext key.

The server wrapper socialTokenVault.js exposes sealSocialToken (encrypt) and readSocialToken (:17), which dual-reads: encrypted values are decrypted; anything else is treated as a legacy signed JWT and verified with FACEBOOK_JWT_SECRET (:24).

Fixing the legacy WhatsApp token

The legacy OfficialWhatsApp.accessToken was stored as a signed JWT, not encryption — its Facebook token was readable by anyone with database access. The one-off script yarn encrypt-wa-tokens (packages/server/package.json, → encryptOfficialWhatsAppTokens.js) re-encrypts those rows: it skips already-encrypted rows (idempotent), decodes the JWT via readSocialToken, re-seals via sealSocialToken, and exits non-zero on any failure. Because readSocialToken dual-reads, the script is safe to run before or after deploy.

Deploy prerequisite: SOCIAL_TOKEN_ENC_KEY (base64, 32 bytes) must be set. The whatsapp-official gateway fails fast at boot if it's missing — it round-trips an encryption probe on startup.

90-day PII redaction

packages/server/src/cronJobs/crm/webhookRetentionCron.js (0 4 * * *, cronJobs.js:193) enforces retention: REDACT_AFTER_DAYS = 90 (:5) — it rewrites WebhookEvent.payload to { redacted: true } for rows older than 90 days that aren't already redacted, and deletes rows after ~365 days. It exempts RECEIVED rows (:9), which are parked, unprocessed events that still need their payload to be replayed. This is the GDPR/PDPL path, since raw payloads carry lead names, phone numbers, and message text.

Two-layer gating: flags + permissions

  • Feature flags decide whether the capability exists for a tenant.

assertFeatureFlagEnabled(prisma, key, companyId) (packages/server/src/utils/featureFlagUtils.js:147) throws if the flag isn't globally active, then evaluates the flag's positive/negative rule DSL against the company. Its docstring is explicit: "Shield permissions gate who may call; this gates whether the capability exists for the tenant." The eight FEATURE_CRM_* flags are seeded by yarn seed-feature-flags (seedFeatureFlags.js:40), all beta-gated (Rule('isBeta','EQUALS',true)).

FlagGates
FEATURE_CRM_INTEGRATIONS_{META,TIKTOK,SNAPCHAT,GOOGLE}per-platform connect
FEATURE_CRM_MARKETING_HUBmarketing posting config, ad credit/invoice, blasts
FEATURE_CRM_INBOX_UNIFIEDinbox config
FEATURE_CRM_AUTOMATIONautomation rules
FEATURE_CRM_REVIEWSreview config, requests, replies, flags
  • GraphQL Shield permissions decide who may call. New CRM permissions include

EDIT_CRM_INTEGRATIONS, VIEW_MARKETING, VIEW_MARKETING_ROI, VIEW_REVIEW, REPLY_REVIEW (reusing EDIT_LEAD_SETTINGS, SEND_WA, VIEW_WA). Query rules at permissions.js:2814; mutation rules at :4378 — all chained with isAuthenticated and hasLeadsAccess.

> One asymmetry to know: the web app gates every CRM surface behind the FEATURE_CRM_* > flags, but the mobile app does not — mobile shows CRM entries based only on > permissions + company.leadsEnabled. See Scope, Gaps & Honest Notes.

Webhook security properties

  • Signature verification — HMAC-SHA256, constant-time (crypto.timingSafeEqual); Meta's

x-hub-signature-256 is checked against FACEBOOK_APP_SECRET, Google's shared google_key against SocialIntegration.webhookKey. Failures return 401.

  • Tenant routing from server-held mappings only — never from sender-controlled payload

fields (webhook-ingest.service.ts:46, lead-form.controller.ts:24). Unroutable events are stored DEAD, not attributed to a guessed company.

  • Idempotent ingestWebhookEvent @@unique([platform, externalEventId]) collapses

redeliveries.

  • Tokens never leave the server — the SAFE_SELECT used by integration resolvers omits

all token fields.