Build Folk

KINDAreplaces $30/mosaves $360/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

A relationship manager built around the fact that the useful context about a person is mostly things they told you, not things you looked up. Manual notes and interaction history are the core, enrichment is a bounded feature that carries an obligation, and every field knows where it came from.

estimated effort multi-daythe files for this build are in the project pack

RuntimeTypeScript with Fastify and PostgreSQLProvenanceEvery field records its source and when it arrivedIdentityDeterministic contact matching with manual mergeInteractionsEmail and calendar metadata by opt-in per accountEnrichmentOptional, per contact, with the source recorded and a notification due date

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. installfree

    Why Runs the server and the mail metadata sync.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. installfree locally

    Why Fields with sources, identities and suppression records want real constraints and indexes.

    Get it Docker or your package manager. open ↗

    Verify psql connects and a non-nullable foreign key can be enforced

  3. free

    Why Phase 3 depends on what scopes the provider actually offers, and some only offer broad ones, which changes what you should store.

    Get it A test account with the provider your contacts use, and read the scope documentation before building the consent screen.

    Verify A token with the narrowest useful scope returns message metadata

  4. decidefree to draft; an hour of advice if this is commercial

    Why Article 14 requires naming the actual source and the basis, and writing that after you have enriched three hundred contacts is writing it too late.

    Get it Decide your basis, draft the notification naming each provider you will use, and write down your position on the disproportionate-effort exemption if you intend to rely on it. open ↗

    Verify A written notification text exists naming providers and the lawful basis

  5. have readyfree

    Why Duplicate matching, identity normalisation and timeline usefulness only become real at a few hundred contacts with messy overlapping data.

    Get it Export from wherever you keep them now, including the duplicates and the entries with only a first name.

    Verify Two hundred contacts import, including at least five genuine duplicates

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- contacts(id, display_name, created_at, notification_due_at, notified_at)
- fields(id, contact_id, key, value, source, source_detail, obtained_at) · source is manual, imported, enriched or from_them
- identities(contact_id, kind, value_normalised, verified) · emails, handles and phone numbers used for matching
- interactions(id, contact_id, kind, at, subject, direction, account_id) · metadata only, never message bodies
- notes(id, contact_id, body, at, author) · the part that actually matters
- enrichments(id, contact_id, provider, requested_at, fields_json, notification_required)
- merges(id, kept_contact_id, merged_contact_id, at, reversible_until) · because merging is destructive and sometimes wrong

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.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://localhost:5432/folkPersonal data about identified people, most of whom did not give it to you directly.
ENRICHMENT_PROVIDERrequirednoneNamed because the notification must name it. None is a valid and simpler configuration.
ENRICHMENT_API_KEYsecretoptionala provider keyOnly if enrichment is enabled. Absent means the manual path, which has no obligation.
NOTIFICATION_DUE_DAYSrequired30From obtaining the data, or first contact if sooner. Thirty is the outer limit, not a target.
MERGE_REVERSAL_DAYSrequired30How long a merge can be undone. A wrong merge is usually noticed weeks later.
STORE_MESSAGE_BODIESrequiredneverPresent as a constant so the answer is visible in configuration. There is no other value.

The build, in order

  1. Contacts, with every field knowing where it came from

    You can always answer where a piece of information about someone came from.

    1. More work than columns and the reason the rest of the build is possible.

      Files src/schema.sql

    2. In the database, not in the application. A field without provenance should not exist.

      Files src/fields.ts

    3. What someone told you is the valuable context. Enrichment is a garnish.

      Files src/contacts.ts

    done when · tick each as it passes
    watch out
    • A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns.
  2. Matching people without merging strangers

    Duplicates are found and merged deliberately, never automatically.

    1. Case, dots, plus addressing and phone formats. All of them cause false negatives.

      Files src/identity.tssrc/identity.test.ts

    2. Propose at most. Combining two people is not recoverable from notes alone.

      Files src/duplicates.ts

    3. Both records retained. Reversal is the only defence against a wrong merge.

      Files src/merge.ts

    done when · tick each as it passes
    watch out
    • Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible.
  3. The timeline, from metadata you were given access to

    You can see when you last spoke to someone without reading their mail.

    1. And document why. A broad scope is a liability even when unused.

      Files src/oauth.ts

    2. Subject, time, direction. Never bodies, and assert that in the suite.

      Files src/interactions.tstests/no-bodies.test.ts

    3. Per account and per contact. Some correspondence should not be in a CRM.

      Files src/exclusions.ts

    done when · tick each as it passes
    watch out
    • Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so.
  4. Enrichment, and the obligation that comes with it

    Looking someone up creates a notification you are tracked against.

    1. Naming the actual source is required. A generic phrase is not sufficient.

      Files src/enrich.ts

    2. One month from obtaining, or first contact, whichever comes first.

      Files src/notifications.ts

    3. It creates hundreds of obligations in one click. The absence is the design.

      Files src/enrich.ts

    done when · tick each as it passes
    watch out
    • Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more crm.