Nordic Engine
et CRM der ringer og lytter

Multi-tenant CRM hvor databasen selv holder virksomhederne adskilt. Softphone i browseren, transskription på to kanaler, AI-feedback

Byggede Nordic Engine til salgsteamet og delte det siden op, så en anden virksomhed kunne sælge fra samme system uden nogensinde at se vores data.

Softwareudvikler, AI-ingeniørTypeScript · Next.js · ReactAug 2025 til maj 2026

Prøv det

Ét klik åbner motoren med opfundne danske leads: åbn pipelinen, og afspil et iscenesat salgsopkald med udskrift og AI-gennemgang. Der ringes ikke til nogen, og demoen kører på sin egen database uden forbindelse til produktion.

Arkitektur

Sælgerens browser: CRM og softphone
Next.js-app: sider og server actions
PostgreSQL med row-level security pr. virksomhed
Twilio: telefonnettet
Webhook for optagelser
Transskribering, kanal for kanal
AI-gennemgang af opkaldet
Formularer, mail og bookinger ind

Forbindelser

  • Sælgerens browser: CRM og softphone → Next.js-app: sider og server actions: pipeline, dialer, indbakke
  • Next.js-app: sider og server actions → PostgreSQL med row-level security pr. virksomhed: forespørgsler som den indloggede bruger
  • Sælgerens browser: CRM og softphone → Twilio: telefonnettet: opkald fra browseren
  • Twilio: telefonnettet → Webhook for optagelser: optagelse på to kanaler
  • Webhook for optagelser → Transskribering, kanal for kanal: lead og sælger hver for sig
  • Transskribering, kanal for kanal → AI-gennemgang af opkaldet: samlet udskrift
  • AI-gennemgang af opkaldet → PostgreSQL med row-level security pr. virksomhed: feedback på opkaldet
  • Formularer, mail og bookinger ind → PostgreSQL med row-level security pr. virksomhed: verificeret, så skrevet

Appen er Next.js med server components til læsning og server actions til skrivning, med validering af alt input. Der er hverken et separat API-lag eller en ORM: siderne spørger PostgreSQL direkte som den indloggede bruger, så databasens row-level security gælder for hver eneste læsning. Skrivninger, der rører flere tabeller, går gennem databasefunktioner, så de enten committer samlet eller slet ikke.

Webhooks og indgange (formularer, indgående mail, telefonnettet, kalenderbookinger) kører uden en brugersession. De verificerer en signatur eller en delt hemmelighed først og skriver først derefter, med afledte idempotency keys, så en gentaget webhook kun lander én gang.

Telefonen kører i browseren. Opkald optages med sælgeren på én kanal og leadet på den anden. Når en optagelse er klar, svarer webhooken med det samme og gør arbejdet færdigt efter svaret: henter lyden, transskriberer hver kanal for sig, fletter dem til én samtale og beder en sprogmodel om feedback. En fejlet transskribering koster udskriften og intet andet; det loggede udfald er allerede gemt.

En tidligere version havde også en AI-telefonagent, der ringede ud gennem en lille bro, jeg skrev, som sendte lyden fra telefonen videre til en stemmemodel i realtid. Virksomheden lukkede agenterne i august 2026 og satte i stedet AI’en bag sælgeren, som udskrifter og feedback.

Flere virksomheder i ét system, adskilt i databasen

Begrænsning. En ny virksomhed flyttede ind med egne sælgere. Appen havde i forvejen omkring 200 forespørgsler og cirka 30 adgangspolitikker. Ingen af virksomhederne måtte kunne se den andens leads, eller bare dens folk.

Muligheder. Tilføj et virksomhedsfilter til hver forespørgsel i appen, kør en database pr. virksomhed, eller lad databasen afgøre, hvilken virksomhed en session kigger på.

Beslutning. Alle tabeller med virksomhedsdata fik en virksomhedskolonne og én restriktiv politik oven i det, de allerede gav adgang til. Én databasefunktion svarer på, hvilken virksomhed sessionen er i, ud fra brugerens aktive medlemskab. Rækker, der oprettes uden session (en webhook, en import), følger det lead, de hører til, via en trigger.

Resultat. Ingen eksisterende forespørgsel eller politik blev skrevet om. Fjerner man en person fra én virksomhed, ophører kun det medlemskab, og skift af virksomhed er et databasekald, der tjekker medlemskabet først.

Bevise adskillelsen i stedet for at tro på den

Begrænsning. “Default deny” er den slags påstand, der står i en kommentar i et år og er forkert hele tiden. Et testscript kan ikke bevise den: den service key, scripts bruger, går helt uden om row-level security og ville melde alt i orden, selv mod et skema helt uden politikker.

Beslutning. Beviset er en databasefunktion, der kører som en almindelig indlogget rolle med en forfalsket identitet, sat præcis dér, hvor auth-laget læser den. Den tjekker, at en udenforstående intet kan læse, at en sælger kun ser det, en sælger må, og lige så vigtigt, at ejerne stadig kan læse deres eget CRM. En politik på false ville bestå de to første tjek og låse virksomheden ude.

Resultat. Adgangsregler kan ændres og bevises igen med ét kald, mod det rigtige skema.

Fra telefonopkald til læsbar samtale

Begrænsning. Automatisk adskillelse af talere virker ikke pålideligt på dansk telefonlyd, transskriberingen tager langt længere tid end telefonnettets webhook-timeout, og en timeout får nettet til at prøve igen, så man betaler for at transskribere det samme opkald flere gange.

Beslutning. Optag på to kanaler til samme pris som én, så adskillelsen af talere er gratis. Svar webhooken med det samme og gør arbejdet efter svaret. Flet de to kanaler efter tidsstempel, saml linjer i træk fra samme taler, og lad leadet vinde, når begge starter samtidig, for så er det næsten altid kunden, der svarer.

Resultat. Hvert optaget opkald bliver til en udskrift, der læses som en samtale, og et langsomt eller fejlet modelkald koster aldrig selve opkaldet.

Kode

Én restriktiv politik pr. tabel, lagt på i en løkke, så ingen eksisterende forespørgsel ændres. Let redigeret til visning: én kommentar forkortet.

supabase/migrations/0092_to_virksomheder_og_roller.sql

do $$
declare
  t text;
begin
  -- Tables whose company comes from the session (or the default company).
  foreach t in array array[
    'companies', 'call_campaigns', 'dialer_state', 'overblik_targets',
    'email_templates', 'mail_sendouts', 'scrape_jobs', 'scrape_finds',
    'contacts', 'activities', 'quotes', 'emails', 'email_drafts',
    'mail_sendout_recipients'
  ] loop
    execute format(
      'alter table public.%I add column workspace_id uuid not null
         default public.default_workspace() references public.workspaces (id)',
      t);
    execute format(
      'create policy %I on public.%I as restrictive for all to authenticated
         using (workspace_id = (select public.current_workspace()))
         with check (workspace_id = (select public.current_workspace()))',
      t || '_this_workspace', t);
  end loop;

To kanaler, transskriberet hver for sig, tilbage til én samtale. Let redigeret til visning: et personnavn fjernet fra kommentaren.

lib/transcribe/merge.ts

/*
 * Ties break toward the LEAD, not toward the seller. When both sides start within
 * the same instant it is almost always the customer answering something.
 */
export function mergeChannels(
  ours: TranscriptSegment[],
  theirs: TranscriptSegment[],
  labels: { ours: string; theirs: string },
): string {
  const lines = [
    ...ours.map((segment) => ({ segment, who: labels.ours, rank: 1 })),
    ...theirs.map((segment) => ({ segment, who: labels.theirs, rank: 0 })),
  ].sort((a, b) => a.segment.start - b.segment.start || a.rank - b.rank);

  const out: string[] = [];
  for (const line of lines) {
    const previous = out.length > 0 ? out[out.length - 1] : null;

    /* Consecutive lines from the same speaker are joined rather than repeating
     * the name every few seconds - whisper segments on breath, not on turns. */
    if (previous?.startsWith(`${line.who}:`)) {
      out[out.length - 1] = `${previous} ${line.segment.text}`;
      continue;
    }
    out.push(`${line.who}: ${line.segment.text}`);
  }

  return out.join("\n");
}

Dialerens rækkefølge: aldrig ringet op først, så længst tid siden sidst, så en fast plads pr. lead. Sortering efter score samlede leads i brancher, og køen hentes i sider, så en ny blanding pr. side ville gentage nogle leads og springe andre over. Let redigeret til visning: kommentar forkortet.

lib/dialer/queue.ts

export function compareLeadsForQueue(a: DialerCompany, b: DialerCompany): number {
  if (a.last_called_at === null && b.last_called_at !== null) return -1;
  if (a.last_called_at !== null && b.last_called_at === null) return 1;
  if (a.last_called_at !== null && b.last_called_at !== null) {
    const diff = new Date(a.last_called_at).getTime() - new Date(b.last_called_at).getTime();
    if (diff !== 0) return diff;
  }

  if (a.lead_slot !== b.lead_slot) return a.lead_slot - b.lead_slot;

  return a.id.localeCompare(b.id);
}

Baggrund

Et salgsteam, der ringer til danske virksomheder hele dagen, skal have leads, opkald, mails og opfølgning samlet ét sted, og en salgschef skal kunne se, hvordan opkaldene går, uden at sidde ved siden af sælgeren. Da en virksomhed mere begyndte at sælge med de samme værktøjer, skulle dens leads, folk og mails samtidig holdes helt adskilt fra den første.

Nordic Engine er et CRM og en platform til opkald, der driver flere virksomheder fra ét system. Sælgeren åbner dialeren, og det næste lead står allerede på skærmen med nøglepersoner og virksomhedsdata. Opkaldet går ud fra browseren. Når samtalen er slut, logger sælgeren udfaldet, og næste lead ringes op af sig selv. Bagefter ligger opkaldet på tidslinjen som en udskrift med begge parter og skriftlig feedback til sælgeren.

Leads kommer ind fra formularer, mail og bookinglinks, bliver matchet mod eksisterende virksomheder og scoret. Hver virksomhed har sine egne leads, kampagner, skabeloner, indbakke og folk, og databasen nægter at vise én virksomheds rækker til en, der arbejder i en anden. Jeg designede systemet og var eneste udvikler.

Det ville jeg gøre anderledes

  • Designe til flere virksomheder fra første tabel. At eftermontere en virksomhedskolonne på alle tabeller virkede, men det krævede en migrering, der skulle køre i én transaktion, og en runde mere for at lukke det, den første lod stå fælles, fx teamlisten og listen over frameldte adresser.
  • Teste idéen, før infrastrukturen bygges. Jeg byggede en stemmebro i realtid til AI-telefonagenter, og cirka en måned senere blev agenterne lukket til fordel for AI bag sælgeren. En håndfuld iscenesatte opkald ville have givet svaret billigere.
  • Se hver graf, før den shippes. Nogle grafer på dashboardet gik ud testet i koden, men aldrig set på en rigtig skærm. Alt, et menneske skal læse, tjekker jeg nu på den renderede side.

Dybdegående

I den rækkefølge systemet var svært at bygge.