HyphaBase

Recipes

Three jobs Sarah actually needed — syncing from her old system, finding people entered twice, and tagging everyone at once.

Whole jobs rather than individual endpoints. Each assumes the api() helper from Server apps and the pages() generator from Long lists.

Keep customers in step with another system

Sarah's old scheduling software is still the place her office adds customers. She wants them copied across nightly until she retires it.

The catch: email is unique per site, so a naive "add everyone" run works once and then fails on every subsequent night. You want an upsert, and there's no upsert endpoint — so look them up first.

A nightly sync doesn't belong in a browser. It needs to finish without a tab staying open, and it needs write scopes you shouldn't ship to a browser.

What the browser should do is start it and show progress:

const { jobId } = await fetch('/internal/sync-customers', { method: 'POST' })
  .then((r) => r.json());

const source = new EventSource(`/internal/sync-customers/${jobId}/progress`);
source.addEventListener('progress', (event) => {
  const { done, total } = JSON.parse(event.data);
  setProgress(done / total);
});

Run the sync itself on your server, with the code in the Server tab.

For the first load of three hundred, use an import instead and keep this for the nightly difference.

Find people entered twice

Sarah suspects the Hendersons are in there twice — once from a phone enquiry, once from the website, with different email addresses. Nothing prevents that: uniqueness is on email, not on people.

function normalisePhone(phone) {
  return (phone ?? '').replace(/\D/g, '').slice(-10);
}

async function findDuplicates() {
  const byName = new Map();
  const byPhone = new Map();

  for await (const batch of pages('/api/modules/clients')) {
    for (const customer of batch) {
      const nameKey = `${customer.first_name}|${customer.last_name}`.toLowerCase();
      const phoneKey = normalisePhone(customer.phone);

      if (nameKey.length > 1) {
        byName.set(nameKey, [...(byName.get(nameKey) ?? []), customer]);
      }
      if (phoneKey.length === 10) {
        byPhone.set(phoneKey, [...(byPhone.get(phoneKey) ?? []), customer]);
      }
    }
  }

  const groups = [...byName.values(), ...byPhone.values()].filter((g) => g.length > 1);

  // De-duplicate the groups themselves — a pair matching on both name and
  // phone would otherwise appear twice.
  const seen = new Set();
  return groups.filter((group) => {
    const key = group.map((c) => c.id).sort().join(',');
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

Report them, don't merge them automatically

There's no merge endpoint, and writing one means deciding whose visits, invoices and notes survive. Two people genuinely do share a name — Sarah has two Dave Wilsons.

Show candidates to a human. When one's confirmed, move the notes and tags onto the keeper and delete the other, which stays reversible.

This walks the whole list, so run it overnight and cache the result rather than recomputing it on every page load.

Tag every existing customer

Sarah's starting a loyalty scheme and wants everyone already on the books marked, before she starts tagging new arrivals differently.

const TAG = {
  name: 'Founding customer',
  icon: 'star',
  color: '#00a87e',
  description: 'With us before the loyalty scheme started.',
};

async function tagEveryone() {
  let tagged = 0;
  let skipped = 0;

  for await (const batch of pages('/api/modules/clients')) {
    for (const customer of batch) {
      // Tags arrive expanded on the customer, so no extra read needed.
      if (customer.traits?.some((tag) => tag.name === TAG.name)) {
        skipped += 1;
        continue;
      }

      await api(`/api/modules/clients/${customer.id}/traits`, {
        method: 'POST',
        headers: { 'Idempotency-Key': `founding-${customer.id}` },
        body: JSON.stringify(TAG),
      });

      tagged += 1;
    }
  }

  console.log(`Tagged ${tagged}, already done ${skipped}`);
}

Checking customer.traits first is what makes this safe to run again — and you will run it again, because a partial failure halfway through three hundred customers is the normal outcome rather than the exceptional one. The Idempotency-Key covers the narrower case of one request being retried.

Remember tags store their own icon and colour per customer, which is why the TAG constant exists. Change the styling later and you're making another pass over all of them; if the vocabulary is genuinely fixed and shared, statuses are the better fit.

Next

On this page