PostgreSQL, accessed through Prisma 7. Schema lives in prisma/schema.prisma;
migrations are version controlled in prisma/migrations. The database is never
edited by hand.
Every entity that can originate from a CRM carries externalSource +
externalId under a unique composite index.
That single constraint is what makes synchronisation idempotent: the sync
engine upserts on that pair, so re-running a full sync never duplicates rows.
Rows created inside the app use externalSource = MANUAL and a null
externalId.
BigInt, in minor units, in a column whose name ends in Minor, paired with a
currency CHAR(3).
Never float. Never Decimal. For UZS the minor unit is 1/100 so’m. Integer
arithmetic stays exact through SUM and AVG, and ports across engines.
See src/server/domain/money.
*AtSource columns hold timestamps as reported by the CRM. createdAt and
updatedAt are our own row lifecycle.
Analytics must use the *AtSource columns. Using createdAt would move
every deal into the current reporting period on the next re-sync.
DealStage.category is our normalised meaning: NEW, IN_PROGRESS, WON,
LOST. Bitrix24 stage IDs map onto it by configuration. No analytics code ever
reads a stage name.
Auth (shape owned by better-auth): user, session, account,
verification. user.role is ADMIN | MANAGER | SALES; user.employeeId
links a login to a salesperson, and a SALES user is scoped to that employee’s
own deals — enforced in the repository query, not by hiding UI.
account.issuer is required and is not optional decoration: better-auth
matches the sign-in account on (issuer, accountId, providerId), so a row
without it looks correct in the table and can never be signed into. For
email+password the value is local:credential.
Organisation: department, employee.
Catalogue: product_category, product, customer.
Pipeline: deal_stage, sales_source, deal, deal_item.
Finance: payment — schema exists, real field mapping unconfirmed. See
BITRIX24.md question 7.
KPI: kpi, kpi_result.
Operations: sync_log, sync_cursor, audit_log.
| Column | Note |
|---|---|
deal.amountMinor |
BigInt minor units; equals the sum of its deal_item.totalMinor |
deal.metadata |
Source fields with no confirmed mapping, preserved so no data is lost |
deal.status |
Derived from the stage category; always consistent with it |
deal_item.totalMinor |
Denormalised quantity × unitPriceMinor, so product analytics aggregates in SQL |
kpi.targetValue |
BigInt. Units depend on metric: minor units for money, a count for deals, basis points for conversion |
kpi_result.achievementBp |
Basis points (10000 = 100.00%). Integer, so no float drift |
sync_log.errorMessage |
Pre-redacted. Never a raw provider response — those can contain credentials |
Chosen to match the actual analytics access patterns rather than added speculatively:
deal(employeeId, createdAtSource) — “this employee’s deals in this period”deal(status, closedAt) — “won deals closed in this period”, the revenue querydeal(createdAtSource), deal(stageId), deal(sourceId), deal(customerId)deal_item(dealId), deal_item(productId)payment(dealId), payment(paidAt)(externalSource, externalId) on every importable entitykpi(periodStart, periodEnd), audit_log(entity, entityId)deal.stageId and deal.employeeId are RESTRICT — deleting a stage or an
employee that still has deals must fail loudly rather than orphan revenue.deal.customerId and deal.sourceId are SET NULL — a deal survives losing
its customer record.deal_item and payment CASCADE from their deal.user.employeeId is SET NULL — deleting an employee record must not delete
the login.npm run db:migrate # create + apply in development
npm run db:deploy # apply in production
npm run db:reset # DESTRUCTIVE: drop, recreate, re-seed
Migration files are committed. Never edit an applied migration; add a new one.
The domain layer declares the enum values as plain string unions so it stays
free of Prisma. src/server/repositories/enumParity.ts asserts at compile
time that the two never drift. Removing a value from either side fails
npm run typecheck — verified by probe.
npm run db:seed runs the demo provider through the real sync engine, so
demo data enters via the same path Bitrix24 data will. It is deterministic:
DEMO_SEED fixes the employees, deals and revenue.
npm run db:seed:users provisions the demo accounts. It converges from any
partial state — user row and credential are ensured independently, because a
run that creates one and fails on the other would otherwise leave an account
that can never be signed into and that re-running would skip.
npm run db:check asserts 11 invariants the schema cannot express, and exits
non-zero on violation so it is usable in CI:
A full sync can sweep rows whose source record has disappeared
(sweepDeleted: true). Without it, a deal deleted in Bitrix24 would sit in
every revenue total permanently.
The sweep is guarded three ways, because deletion is the one irreversible operation here: never on incremental runs (a changed-records read says nothing about what still exists), never after a FAILED or PARTIAL run (a transient network error yields a short read), and never on an empty read (that is a misconfiguration, not a real emptying).
Enabled for the demo seed. Opt-in for Bitrix24 until the retention policy is agreed — see BITRIX24.md §10.