# HubSpot Data Cleaning – Algeco Nordic

Prosjektplan og kontekst for gjennomføring. Skrevet for å gi en ny Claude Code-økt (på webserveren) full kontekst uten tilgang til den opprinnelige planleggingssamtalen.

**Sist oppdatert:** 2026-08-05, etter kodegjennomgang og forbedringsrunde (se "Kodegjennomgang og forbedringer (2026-08-05)" nederst). Forrige oppdatering 2026-08-04 etter første arbeidsøkt (infrastruktur + Fase 1/2-kalibrering). Se "Status" under hver fase-seksjon for hva som faktisk er gjort og funnet.

## Bakgrunn

Kravspec: https://info.algeco.no/hubspot-data-cleaning (internt Algeco Nordic-dokument, 2026) — lokal kopi ligger i `raw_data/project_spec.html`. Sjekket 2026-08-04: fase-inndeling, slettekriterier, tilleggskriterier og sperren mot å slette kontakter med aktive deals stemmer overens mellom denne planen og spec-dokumentet. Spec-en oppgir ingen tidsestimater — 17–30t-anslaget under er Thomas' eget.

Mål: rydde Algeco Nordic sin HubSpot-portal gjennom tre sekvensielle faser for å bedre datakvalitet og legge grunnlag for bedre salgs-/markedsføringsprosesser. **Fasene skal kjøres i rekkefølge — fase 2 starter ikke før fase 1 er godkjent og fullført, osv.**

Utførende: Thomas (thomas@springagency.com), god erfaring med HubSpot og custom dev mot HubSpot API. Ingen direkte AI-tilgang til HubSpot-portalen — alt kjøres via egenskrevne skript autentisert med et HubSpot Private App-token.

Grovt tidsestimat: **17–30 timer totalt**

## Infrastruktur — avklart 2026-08-04

Prosjektmappen er **ikke** en isolert lokal utviklingsmappe — den er koblet direkte mot en live server via PhpStorm SFTP-deployment:

- **Deploy:** PhpStorm "Upload to..." (SFTP, `development.techweb.no`), hele prosjektroten er webroot (`/var/www/algeco` → `https://algeco.zapto.org`). Auto-upload fanger kun opp endringer gjort inne i PhpStorm-økten — filer skrevet utenfra (f.eks. av Claude Code) må lastes opp manuelt av Thomas.
- **Database:** MySQL, ikke SQLite som først antatt. Kobles til via `classes/db.class.php` (127.0.0.1, database `algeco_hubspot_clean`). Finnes både lokalt og på serveren.
- **`classes/`-mappen** (`db.class.php`, `crud.class.php`, `fetch.class.php`, `functions.class.php`) er delt boilerplate gjenbrukt fra andre klientprosjekter på samme infrastruktur (spor av Memscap, Best Western, Pipedrive-baserte CRM-er, nettbutikk, eiendom/leietaker-system) — ikke skrevet spesifikt for Algeco. Kun `db.class.php` er i praktisk bruk her (tilkoblingsklasse).
- **Kjøremodell:** siden Claude Code ikke har lokal PHP 8 og ikke kan SSH inn på serveren, kjøres alle skript ved at (1) Claude Code skriver PHP-filen lokalt, (2) Thomas laster den opp manuelt via PhpStorm, (3) Claude Code henter resultatet via nettleser (Chrome-automatisering) mot `https://algeco.zapto.org/<script>.php`.

**Sikkerhetsfunn og -fiks:** Prosjektroten (inkl. `raw_data/` med fullt PII for alle kontakter/firmaer) lå åpent tilgjengelig på internett med directory listing på, siden hele mappen er webroot. Fikset med en `.htaccess` i prosjektroten som begrenser tilgang til Thomas' IP (81.191.46.8). *Ikke verifisert fra en ekstern IP* — kun bekreftet at siden fortsatt laster fra Thomas' egen (whitelistede) IP.

## Datagrunnlag

CSV-eksport ligger i `raw_data/` (kopiert inn før denne økten startet, IKKE på server-webroot lenger enn nødvendig — se sikkerhetsfiks over):

| Fil | Faktisk radantall (bekreftet) | Innhold |
|---|---|---|
| `all-contacts.csv` | **81 873** | Full kontakteksport, ~280 kolonner |
| `all-companies.csv` | **36 873** | Full firmaeksport, inkl. `Org.no.:`-kolonne, adresse, owner |
| `all-deals.csv` | **39 051** | Full deal-eksport, inkl. pipeline/stage, beløp, associated contact/company IDs |

**Merk:** radantallene avviker fra de opprinnelige anslagene i denne planen (121 015 / 36 886 / 39 189) — bekreftet uavhengig (linjetelling i selve filene matcher skriptenes tellinger nøyaktig), så det er de faktiske filene som har færre rader enn først antatt, ikke en telle-feil.

**Datakvalitetsobservasjon — mulig syntetisk/test-data:** alle tidsstempler i eksporten ligger på selve kjøredatoen (2026-08-04), og et betydelig antall kontakter (8 419 / 10,3 %) har tydelig tilfeldig genererte navn (f.eks. "frFWOhEhMeZEnQXaxYlwiEd" hos firma "Nwcfubmitp LLC" — se Fase 1-status). Dette kan være ekte bot/spam-kontakter i portalen (spec-en nevner nettopp dette som en kjent kategori), men mønsteret er konsistent nok til at det er verdt å bekrefte med Algeco om denne eksporten er ekte produksjonsdata eller en generert test-eksport, før tallene under brukes til å love et endelig tidsestimat.

ERP-eksport for fase 3 er **ikke mottatt ennå** — må skaffes fra Algeco før fase 3 kan planlegges i detalj (se åpne spørsmål).

## Teknisk stack — faktisk i bruk (oppdatert fra opprinnelig plan)

Opprinnelig plan antok SQLite + ingen databaseserver. Det er **ikke** det som faktisk brukes:

- **Språk:** PHP (kjørt via nettleser-forespørsler mot webroot, se "Infrastruktur")
- **HubSpot-integrasjon:** `hubspot/api-client` (installert i `vendor/`, ikke tatt i bruk ennå — alt arbeid så langt er lokalt mot CSV/database, ingen HubSpot API-kall gjort)
- **HTTP/retry:** Guzzle finnes i `vendor/` (transitiv avhengighet av hubspot-klienten)
- **CSV-lesing:** innebygd `fgetcsv()` — `league/csv` er ikke installert og ikke nødvendig så langt
- **Database:** MySQL (`algeco_hubspot_clean`), ikke SQLite. Tre tabeller bygget: `algeco_contacts`, `algeco_companies`, `algeco_deals` — kjernefelter som egne kolonner (e-post, org.nr, aktivitetsdatoer, associations, m.m.) + en `raw_json`-kolonne med hele HubSpot-raden for alt annet. Alle tre er fullt importert (81 873 / 36 873 / 39 051 rader, matcher CSV-ene eksakt).
- **Rapporter (dry-run):** ren CSV/HTML fra PHP, ikke PhpSpreadsheet ennå (ikke installert). Fungerer godt nok for gjennomgang i Excel; kan legges til senere hvis formatert .xlsx med flere ark ønskes.
- **Fuzzy matching:** enkel normalisering i PHP (lowercase, fjerne AS/ASA/AB/Ltd-suffikser) for firmanavn-duplikater. `levenshtein()`/`similar_text()`/`soundex()` ikke tatt i bruk ennå.

**Viktige feil funnet og rettet i `classes/db.class.php` under import:**
1. Tilkoblingens charset var satt til `utf8` (gammel 3-byte-variant) mens tabellene er `utf8mb4` — rader med 4-byte UTF-8-tegn feilet stille på insert (mysqli kaster ikke feil som standard). Rettet til `utf8mb4`.
2. La til `mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT)` slik at fremtidige databasefeil kaster exception i stedet for å forsvinne stille — kritisk siden den forrige feilen kostet ~2 500 rader uten noen feilmelding.
3. MySQLs standard-collation er aksent-ufølsom (`ø` ≈ `o` i `GROUP BY`/`IN`) — ga falske positive/negative i duplikat-tellingen i Fase 2 til alle sammenligninger ble tvunget til `utf8mb4_bin` (byte-eksakt).

## Arbeidsprinsipp — alle faser

1. **Dry-run først** — skriptet analyserer og genererer en rapport med foreslåtte endringer + begrunnelse per rad. Ingen skriving til HubSpot i denne modusen.
2. **Manuell godkjenning** av rapporten før noe kjøres mot portalen.
3. **Faktisk kjøring** via batch-API, med full logg (audit trail) over hva som ble endret/slettet — nødvendig siden sletting/merge er irreversibelt.
4. **Sign-off-punkt** mellom hver fase før neste starter.

## Fase 1 — Sletting av inaktive kontakter (est. 4–7t)

**Kjernekriterier (fra spec):**
- Hard bounce — permanent ugyldige e-postadresser (`Email hard bounce reason`-kolonnen finnes i eksporten)
- Inaktive kontakter — ingen aktivitet siste 2 år (`Last Activity Date`)

**Sikkerhetssperre:** kontakter tilknyttet eksisterende deals skal **ikke** slettes i denne fasen — sjekkes mot `Associated Deal IDs`.

**Tilleggskriterier fra spec (status: fortsatt ikke bekreftet om inkludert i 4–7t-estimatet — avklar med Algeco):**
- Kontakter uten e-post og uten firmatilknytning
- Unsubscribed fra all e-post + ingen salgshistorikk
- Test-/interne kontakter feilaktig registrert
- Tydelig ugyldig/manglende data (kun fornavn, ingen firmatilknytning)

**Leveranse:** dry-run Excel-liste → godkjenning → batch-sletting → logg.

### Status (2026-08-04): dry-run bygget og kjørt

`profile.php` (lokal CSV-telling) og `fase1_dryrun.php` (mot database, med reell deal-kobling) er bygget og kjørt mot de 81 873 kontaktene:

| | Antall | Andel |
|---|---|---|
| Flagget av minst ett kriterium | 71 585 | 87,4 % |
| → Foreslått **SLETT** (kjernekriterium, ingen deal) | 56 383 | |
| → **BEHOLD** (kjernekriterium matcher, men har aktiv deal) | 13 519 | |
| → **VURDER** (kun tilleggskriterium, ikke kjernekriterium) | 1 683 | |

Delkriterier bak dette (fra `profile.php`, CSV-nivå): hard bounce 6 660 (8,1 %), inaktiv >2 år 48 951 (59,8 %), aldri registrert aktivitet 20 362 (24,9 %), ingen e-post+firma 37, avmeldt+ingen salg 4 416 (5,4 %), bot-/spam-mistenkte navn 8 419 (10,3 % — heuristikk basert på lav vokalandel/uregelmessig store-små bokstaver, se merknad om syntetisk data over; noen sannsynlige falske positiver på ekte norske navn som "Rune Lied", "Ole Olsen").

Full CSV med begrunnelse per rad: `fase1_dryrun.php?download=1`.

## Fase 2 — Duplikater og datavask (est. 7–12t)

**Kjerneoppgaver:**
- Identifisere og slå sammen duplikate kontakter (match på e-post, evt. navn+firma fuzzy)
- Identifisere og slå sammen duplikate firmaer (match på org.nr → firmanavn fuzzy)
- Definere merge-strategi: hvilket record er "master", hvilke felt beholdes ved konflikt (nyeste/mest komplette/høyest engagement)

**Tilleggsoppgaver:**
- Standardisere firmanavn og bransjekoder
- Rydde eierskap (owner) på kontakter/firmaer
- Normalisere telefonnummer og adressefelt til felles format
- Rydde utdaterte tags/lister/segmenter
- Kartlegge og fjerne ubrukte custom properties

### Status (2026-08-04): kalibrering bygget og kjørt

`fase2_calibrering.php` bygget mot databasen (`?download=contacts` / `?download=companies_org` / `?download=companies_name` for fulle lister):

| | Grupper | Firmaer/kontakter berørt |
|---|---|---|
| Duplikate kontakter (eksakt e-post) | 0 | 0 |
| Duplikate firmaer (org.nr) | 702 | 1 639 |
| Duplikate firmaer (normalisert navn) | 2 387 | 5 233 |

**To funn som krever et forretningsvalg før faktisk sammenslåing, ikke løst i kode:**

1. **Kjedebutikker/filialer** deler ofte ett org.nr på tvers av mange navngitte lokasjoner (eksempler funnet: Byggmakker med 41 filialer, UCO, Veidekke, Renta, Cramo, MOWI, Block Watne). Om disse skal slås sammen til ett firma eller beholdes som separate poster (egne kontakter/deals per filial) er en beslutning Algeco bør ta — `fase2_calibrering.php` merker nå grupper med >4 medlemmer med en advarsel i CSV-en i stedet for å anta.
2. **"212000"-gruppen** (org.nr) samler mange ulike svenske kommuner (Uppsala, Östersund, Lund m.fl.). **Konklusjon (2026-08-05):** svenske offentlige org.nr er 10 siffer (`212000-XXXX`); når mange ulike kommuner deler eksakt verdi `212000`, er feltet avkortet i disse postene — et datakvalitetsproblem, ikke en duplikatgruppe. Eksakt `212000` behandles nå som placeholder i `fase2_calibrering.php` (10-sifrede verdier telles fortsatt). Gjenstår å stikkprøve om trunkering rammer svenske org.nr generelt, og feltet bør re-berikes (ERP/Bolagsverket) før det evt. gjøres til unik property i Fase 3.

Placeholder-verdier i org.nr-feltet (`0`, `123456789`, `12345678`, alle-samme-siffer) er filtrert bort fra tellingen — disse samlet i utgangspunktet hundrevis av helt urelaterte firmaer i falske "duplikat"-grupper.

## Fase 3 — ERP-matching og unik ID (est. 6–11t)

**Kjerneoppgaver:**
- Importere ERP-data og koble til eksisterende kontakter/firmaer
- Trinnvis matching: **org.nr** (høy konfidens, kan trolig auto-godkjennes) → **firmanavn** (fuzzy, medium konfidens) → **navn + adresse** (lav konfidens, må flagges for manuell verifisering)
- Opprette ny custom property, f.eks. `ERP Company Number`
- Oppdatere HubSpot via batch API **etter** verifisering av treff

**Eksempler på ERP-data å berike med (fra spec):** kundenummer/-status, kontrakt-/kjøpshistorikk, fakturaadresse og juridisk firmanavn, kredittstatus/betalingshistorikk, ansvarlig selger/account manager.

**Blokkert på:** ERP-eksport fra Algeco (format/felter ikke mottatt ennå).

**Kalibreringstall fra Fase 1/2-arbeidet:** kun 14 222 av 36 873 firmaer (38,6 %) har org.nr registrert i det hele tatt — under 40 % kan gå gjennom den raske org.nr-matchingen, resten må gjennom tregere fuzzy/manuell matching. Verdt å ta med i estimatet når Fase 3 planlegges i detalj.

## Åpne spørsmål (bør avklares med Algeco før/under prosjektet)

1. Er de fire "additional actions to consider" i fase 1 inkludert i 4–7t-estimatet, eller separat tilleggsarbeid?
2. Hva er nøyaktig format/felter på ERP-eksporten til fase 3 (CSV? hvilke kolonner?)
3. Hvilke custom properties regnes som "ikke lenger i bruk" — krever trolig innspill fra flere interessenter (salg/marked), ikke bare datavurdering
4. Match-regler for duplikater: er e-post alltid nok for kontakt-duplikater, eller kreves navn+firma-fuzzy også? (Kalibrering viser 0 eksakte e-post-duplikater — betyr det navn+firma-fuzzy er nødvendig for å finne noe reelt volum blant kontakter?)
5. **Nytt:** er `raw_data`-eksporten ekte produksjonsdata eller en generert test-eksport? (Se datakvalitetsobservasjon over — påvirker hvor mye vekt tallene i denne planen bør ha.)
6. **Nytt:** skal kjedebutikker/filialer med felles org.nr (Byggmakker, UCO, Veidekke m.fl.) slås sammen i Fase 2, eller er det ønskelig å beholde dem som separate firmaposter?
7. **Delvis besvart (2026-08-05):** `Org.no.:`-feltet ER trunkert for postene med eksakt verdi `212000` (svensk offentlig sektor, se Fase 2-status). Gjenstår: gjelder trunkering svenske org.nr generelt, og skal feltet re-berikes fra ERP/Bolagsverket i Fase 3?
8. **Nytt (2026-08-05):** hvilket HubSpot-abonnement/tier har portalen (spesielt om Operations Hub Pro finnes)? Avgjør om normalisering av telefon/navn/adresser i Fase 2 kan gjøres med innebygde "Format data"-workflows i stedet for skript (se HubSpot-vurderingen under).

## Kodegjennomgang og forbedringer (2026-08-05)

Gjort lokalt av Claude Code — **må lastes opp manuelt via PhpStorm før de gjelder på serveren**, og `fase1_execute.php` er ikke syntaks-verifisert lokalt (ingen lokal PHP) — kjør forhåndsvisningen én gang etter opplasting for å bekrefte:

1. **`fase1_execute.php` skrevet om** (var ikke kjørt ennå — ingen token):
   - Kjører nå som **forhåndsvisning som standard**; faktisk sletting krever `&confirm=DELETE`, og `&limit=100` finnes for den planlagte test-batchen.
   - **Audit-trail per kontakt** i ny tabell `algeco_deletion_log` (status deleted/skipped_live_deal/failed + begrunnelse + run_id) — før ble bare batch-antall logget, ikke hvilke ID-er. Re-kjøringer hopper over allerede slettede (resumbart).
   - **Live re-sjekk av deal-tilknytning** mot HubSpot (v4 associations batch read) per batch før sletting, siden databasen er et snapshot fra 2026-08-04 og kontakter kan ha fått deals siden. Kan skrus av med `&skip_live_check=1`.
   - Retry på 429/5xx via HubSpot-klientens innebygde retry-middleware; feilet batch faller tilbake til individuell sletting så én dårlig ID ikke blokkerer 99 andre.
2. **Sikkerhet:** `test_hubspot_connection.php` godtok token via `?token=` i URL (havner i access-logg/historikk) — fjernet. `EXECUTION_ALLOWED`-sperre lagt på `schema_check.php`, `fase2_chain_check.php`, `debug_swedish_org.php`. `.htaccess` utvidet med `Options -Indexes` + deny på `config.php`, composer-filer, `*.md/*.log/*.sql/*.csv`; egne deny-alt `.htaccess` i `raw_data/`, `classes/`, `logs/`, `vendor/`.
3. **Bug-fikser:** `fase2_chain_check.php`/`debug_swedish_org.php` spurte etter kolonnene `city`/`country_region` som ikke finnes i skjemaet (ville kastet exception under strict mysqli) — henter nå fra `raw_json`. IN-liste-byggingen for duplikat-eposter i `fase2_calibrering.php` var skjør (escape-så-splitt-på-komma) — forenklet til korrekt quoting.
4. **Bot-heuristikken** (`profile.php` + `fase1_dryrun.php`) teller nå nordiske vokaler (æøåäö) og håndterer multibyte riktig — navn som "Bjørn Sørbø" ble før feilflagget som bot pga. manglende æ/ø/å i vokallisten. **Merk:** tallet 8 419 bot-mistenkte fra 2026-08-04 vil endre seg (nedover) ved neste kjøring.

**Gjenstående anbefalinger (ikke gjort i kode):**
- Rotér DB-passordet og flytt det ut av `config.php` hvis mulig — det ligger i klartekst i webroot og i git-historikken.
- `?key=algeco_secret_2024` er svak og havner i access-loggen — vurder å bytte til en tilfeldig lang verdi (krever bare at Thomas oppdaterer sine egne URL-er). IP-sperren er uansett primærgaten.
- `.htaccess`-endringene er fortsatt ikke verifisert fra en ekstern IP.
- `classes/crud.class.php`, `fetch.class.php`, `functions.class.php` er ubrukt boilerplate i dette prosjektet — vurder å slette fra serveren for å redusere angrepsflate.

## Neste steg

1. ~~Kopier CSV-ene til serveren~~ — gjort (lå allerede i `raw_data/` ved økstart)
2. ~~Sett opp Composer-prosjekt~~ — delvis: `hubspot/api-client` installert, `phpoffice/phpspreadsheet` og `league/csv` ikke lagt til (ikke brukt ennå)
3. Opprett HubSpot Private App-token (scopes: contacts, companies, deals — read/write) på serveren — **ikke gjort ennå**, ingen HubSpot API-kall er utført i prosjektet så langt
4. ~~Bygg profileringsskript~~ — gjort (`profile.php`, `fase1_dryrun.php`, `fase2_calibrering.php`), se Status-seksjonene over
5. Avklar de nye åpne spørsmålene (5–7 over) med Algeco før tallene brukes til et endelig estimat
6. Fase 1: hent sign-off på dry-run-listen (`fase1_dryrun.php?download=1`), deretter bygg faktisk batch-sletting mot HubSpot API (krever Private App-token, se pkt. 3)
