Build Bigin
KINDAreplaces $9/mosaves $108/yrback to the verdict
A small CRM whose hard problems are importing a messy contact list without creating five of everybody, merging duplicates in a way that can be reversed, and recording stage changes as events so that time in stage is a fact rather than a guess.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the application and the database together so the first run is one command and the restore is reproducible.
Get it docker.com, or your package manager. open ↗
Verify
docker compose version prints a version - installfree
Why Runs the web application and the import worker.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - installfree
Why Normalising a phone number to the international format needs the current numbering plan for each country, and hand-rolled stripping of spaces and brackets matches the wrong records.
Get it Any well-maintained port of the widely used phone number library for your runtime.
Verify
Three national formats of one number parse to the same international value - have readyfree
Why Every interesting failure comes from real data: duplicate rows, a person at two companies, an empty required column, a number stored as text.
Get it Export contacts from whatever you use now, or take a messy spreadsheet you already have.
Verify
The file imports with a dry run report you can read - decidefree
Why Each becomes a test, and deciding them after seeing the results means choosing whatever the code already did.
Get it Write down which fields make a duplicate, how many days without activity makes a deal stale, and exactly what an erasure removes.
Verify
All three appear in configuration and are named in tests
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- people(id, given_name, family_name, email, email_key, phone_e164, company_id, owner, created_at, updated_at, merged_into_id) - companies(id, name, name_key, domain, domain_key, created_at, updated_at, merged_into_id) - deals(id, title, person_id, company_id, pipeline_id, stage_id, amount_minor, currency, expected_close, state, created_at, closed_at) - stage_events(id, deal_id, from_stage_id, to_stage_id, at, actor, note) · append-only, and the only source of time in stage - interactions(id, subject_type, subject_id, kind, at, summary, body, created_by) · calls, meetings and emails logged by hand - tasks(id, subject_type, subject_id, title, due_at, done_at, owner) · the next action, which is the reason anyone opens a CRM - merges(id, kind, survivor_id, absorbed_id, field_choices_json, at, undone_at) · enough to put both records back - import_rows(id, batch_id, source_key, raw_json, decision, matched_id, error) · every row of every import, including the rejected ones - Nothing is updated in place that a report later depends on. Stage changes, merges and imports are all rows you can read back.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://crm:crm@db:5432/crm | The whole CRM. Backed up by the documented dump command. |
ATTACHMENT_DIR | required | ./data/attachments | Files attached to records. Included in the export archive and in the backup. |
DEFAULT_COUNTRY | required | GB | Used when a phone number in an import has no country code. |
STALE_DEAL_DAYS | required | 21 | Days without an interaction before a deal is called stale. Shown beside the number. |
FORECAST_MIN_SAMPLE | required | 20 | Below this many closed deals in a stage, no probability is shown at all. |
EXPORT_DIR | required | ./data/exports | Where the export archive is written. Clear it after downloading. |
The build, in order
People, companies and a history that is only appended
Every interaction and every stage change is a row, and nothing that a report reads is overwritten.
The column can be derived from events; events cannot be derived from a column.
Files
src/deals.tssrc/events.tsAgainst a hand-worked example including a deal that moved backwards.
Files
tests/time-in-stage.test.tsTwo sources of history is one source of arguments.
Files
src/timeline.ts
done when · tick each as it passeswatch out- Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report.
Importing a real contact list
A messy CSV imported twice produces the same records, not twice as many.
It is what makes a second run an update instead of a duplicate.
Files
src/import/rows.tsThe counts from the dry run are the contract the real run must meet.
Files
src/import/dryrun.tstests/import.test.tsMatching happens on the stored key, never on the raw string.
Files
src/normalise.tstests/normalise.test.ts
done when · tick each as it passeswatch out- Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it.
Merging duplicates without losing either of them
A merge is a reversible decision, and an over-eager merge can be put back.
It holds the notes somebody will look for.
Files
src/merge.tsUnmerge is only possible if you know what was chosen.
Files
src/merge.tstests/unmerge.test.tsAn automatic merge on a shared company is how two customers become one.
Files
src/dedupe.tstests/dedupe.test.ts
done when · tick each as it passeswatch out- Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed.
The next action, and honest pipeline numbers
Opening the app shows what to do today, and every figure on the dashboard can be traced to rows.
A dashboard tile that cannot be drilled into is decoration.
Files
src/dashboard.tsWith the sample size beside it, so four deals looks like four deals.
Files
src/forecast.tstests/forecast.test.tsOtherwise the number means whatever the reader assumes.
Files
src/dashboard.ts
done when · tick each as it passeswatch out- Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one.
Leaving, and being asked to forget someoneproduct builder
Everything can be exported, restored, and one person can be erased without destroying the audit trail.
An export that requires database access is not an export.
Files
src/export.tsscripts/export.shWrite down the commands that worked, not the ones you meant to run.
Files
docs/restore.mdSo that last quarter still reproduces after a deletion request.
Files
src/erasure.tstests/erasure.test.ts
done when · tick each as it passeswatch out- Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when.
That is the whole plan for Bigin. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Automatic mailbox and calendar ingestion.
- Commercial enrichment data.
- Telephony and call recording.
- Enterprise forecasting and quota management.
- Zoho ecosystem, telephony, forms, integrations, and mobile apps
- automatic email and calendar capture
- data enrichment
- large integration ecosystem
- enterprise reporting
- A shared read-only view of one pipeline, which needs a token rather than a permission model
- Email logging by forwarding to an address the app watches, which is far simpler than mailbox sync
- A weekly digest of stale deals written to a file, so nobody has to remember to look
Need the files? The project pack on the verdict page hands your agent the whole brief · more crm.