# Selidba na Unlimited.rs (cPanel + Node.js App Manager)

Ovaj vodič prebacuje BiznisPortal sa Vercela na sopstveni hosting, bez menjanja
baze (Supabase) ni skladišta slika (Cloudflare R2) — oni ostaju gde jesu, menja
se samo mesto gde se Next.js server izvršava.

**Pravilo broj jedan: domen se ne pomera dok sve ne proradi na probnoj adresi.**
Vercel ostaje uključen i netaknut tokom celog ovog procesa — to je siguronosna
mreža. Tek kad je sve provereno, DNS se prebacuje.

## Šta ostaje isto, šta se menja

| | Vercel (sada) | Unlimited.rs (posle) |
|---|---|---|
| Baza (Supabase) | ista | ista, bez izmene |
| Slike (Cloudflare R2) | isto | isto, bez izmene |
| Next.js server | Vercel-ov serverless sloj | `server.js` preko Passenger-a |
| Zakazani poslovi | `vercel.json` → Vercel Cron | cPanel Cron Jobs → `scripts/cron-cpanel.sh` |
| ISR (keš strana) | plaća se po regeneraciji | pišе na disk, bez naplate po pisanju |

Poslednji red je i glavni razlog za selidbu: ISR Writes je bio najveća stavka
na Vercel računu upravo zato što botovi (Google, Bing, ChatGPT, Perplexity...)
neprekidno obilaze sajt, a svaki obilazak nekad znači ponovno renderovanje
strane = trošak. Na sopstvenom serveru je to obično pisanje na disk — plaćaš
paket, ne broj poseta.

## 0. Pre svega — zaustavi krvarenje na Vercelu (2 minuta, ne čeka ostatak)

Bez obzira kad selidba bude gotova, ovo uradi odmah:

`Vercel dashboard → Settings → Billing → Spend Management → Spend Cap` →
postavi plafon (npr. $20) na **Pause**. Sajt se ne gasi dok se plafon ne
dostigne, a preko njega se **nikad** ne naplaćuje.

## 1. Node.js App u cPanel-u

`cPanel → Setup Node.js App → Create Application`

- **Node.js version:** najnovija ponuđena verzija ≥ 20.9 (Next.js 16 to traži;
  uzmi najnoviju LTS ako je ponuđena, npr. 22).
- **Application mode:** Production
- **Application root:** npr. `biznisportal.rs` (folder u tvom home direktorijumu)
- **Application URL:** **za sada probna poddomena**, npr. `test.biznisportal.rs`
  ili poddomena koju cPanel ponudi — **ne glavni domen još**.

  **Poddomen mora VEĆ postojati** kao pravi cPanel domen pre nego što ga
  izabereš ovde (`cPanel → Domains → Create A New Domain`, ili stariji
  „Subdomains" ekran) — to je ono što stvara DNS A zapis i vhost. Ako
  `test.biznisportal.rs` ne postoji na spisku domena, Application URL polje
  ga ne tretira kao poddomen nego kao PUTANJU ispod glavnog domena
  (npr. `biznisportal.rs/test.biznisportal.rs`), koja nikad neće raditi jer
  glavni domen i dalje pokazuje na Vercel. Napravi poddomen prvo, tek onda
  ga izaberi ovde iz padajuće liste.
- **Application startup file:** `server.js`

Sačuvaj, ali još ne pokreći — prvo treba kod.

## 2. Git Version Control — povuci kod

`cPanel → Git Version Control → Create`

- **Clone a Repository**
- **Repository URL:** `https://github.com/Smartechor/biznisportal.git`
- **Repository Path:** `~/repositories/<naziv>` (npr. `~/repositories/biznisportal.rs`)
- **Branch:** `main`

**Važno:** cPanel klonira repo u `~/repositories/<naziv>/` — to NIJE isti folder
kao Application root iz koraka 1 (to je npr. `~/biznisportal.rs/`, folder koji
Passenger stvarno servira). Posle svakog `git pull` treba ručno preneti fajlove
u folder koji se servira:

```bash
cd ~/repositories/biznisportal.rs && git pull && cp -a $(ls -A | grep -v '^\.git') ~/biznisportal.rs/
```

`$(ls -A | grep -v '^\.git')` namerno izostavlja `.git` — obično `cp -a` bez
ovog filtera kopira i `.git` folder (samo-za-čitanje git objekti), pa sledeći
pokušaj sinhronizacije puca na "permission denied". `rsync` obično nije
instaliran na cPanel nalozima, pa se ne oslanjaj na njega.

## 3. Environment varijable

Na svom računaru (gde možeš da se uloguješ u Vercel):

```bash
vercel env pull .env.production --environment=production
```

Ovo pravi fajl sa **stvarnim vrednostima** iz Vercela. Ne šalji taj fajl
nikome — koristi ga samo kao izvor da prekopiraš vrednosti.

U cPanel-u: `Setup Node.js App → (otvori aplikaciju) → Environment Variables
→ + ADD VARIABLE`. Prekopiraj **svaku** promenljivu iz `.env.production` —
ima ih preko 150, većina su opcione oznake funkcija, ali je najsigurnije
preneti sve da ništa ne prestane da radi.

**Posebno proveri da su ovde:**
- `DATABASE_URL`, `DIRECT_URL` (Supabase — ne menjaju se)
- `AUTH_SECRET`, `CRON_SECRET`, `VISITOR_AUTH_SECRET`, `SPAM_HMAC_SECRET`
- `MEDIA_*` (R2 — ne menjaju se)
- `NOTIFICATIONS_SMTP_*` (mejl)
- `NEXT_PUBLIC_SITE_URL` i `SITE_URL` — **za probni period stavi probnu
  adresu** (`https://test.biznisportal.rs`), promeni na pravi domen tek u
  koraku 8.

**`DATABASE_URL` — promeni port sa 6543 na 5432.** Supabase nudi dva pooler
porta na istom hostname-u: `6543` (transaction mode, za serverless kao
Vercel) i `5432` (session mode, za trajne servere kao ovaj). Mnogi deljeni
hosting nalozi (uključujući unlimited.rs) blokiraju odlazeće konekcije na
port 6543 — build i runtime tada padaju sa `ECONNREFUSED` na svaki Prisma
upit, bez jasne poruke da je uzrok mrežni blok. Proveri koji je port otvoren
pre nego što izgubiš vreme na drugim objašnjenjima:

```bash
timeout 5 bash -c 'cat < /dev/null > /dev/tcp/aws-0-eu-west-1.pooler.supabase.com/6543' && echo "6543 OTVOREN" || echo "6543 BLOKIRAN"
timeout 5 bash -c 'cat < /dev/null > /dev/tcp/aws-0-eu-west-1.pooler.supabase.com/5432' && echo "5432 OTVOREN" || echo "5432 BLOKIRAN"
```

Ako je 6543 blokiran, promeni SAMO port broj u `DATABASE_URL` (isti hostname,
korisnik, lozinka) na serveru — `.env.production` je van git-a pa ova izmena
ne utiče na Vercel, koji i dalje koristi 6543.

## 4. Instalacija i build

U Terminalu (SSH ili cPanel-ov browser Terminal), aktiviraj virtuelno
okruženje i uđi u folder (panel na „Edit" strani aplikacije prikazuje tačnu
komandu za „Enter to the virtual environment"):

```bash
source /home/<korisnik>/nodevenv/biznisportal.rs/<node-verzija>/bin/activate
cd ~/biznisportal.rs
```

**Instalacija — sa dve zastavice, ne obično `npm install`:**

```bash
npm install --legacy-peer-deps --include=dev
```

- `--legacy-peer-deps`: jedan tiptap paket unutar sebe traži drugu verziju
  susednog paketa nego što mi tražimo u `package.json` (njihova deklaracija,
  van naše kontrole) — ova zastavica govori npm-u da veruje našem tačnom izboru.
- `--include=dev`: cPanel-ov „Production" mod postavlja `NODE_ENV=production`,
  a npm u tom režimu inače PRESKAČE `devDependencies` (tu živi `prisma` alat
  koji je build-u neophodan) — ova zastavica ga prisiljava da ih ipak instalira.

**Build — `build:selfhost`, NE obično `npm run build`:**

```bash
npm run build:selfhost
```

cPanel drži stvarne pakete u `~/nodevenv/...` folderu i pravi simboličku vezu
`node_modules → ~/nodevenv/biznisportal.rs/<verzija>/lib/node_modules`.
Next.js-ov noviji bundler (Turbopack, podrazumevan za `next build`) iz
bezbednosnih razloga odbija da prati veze koje izlaze iz projektnog foldera
(„Symlink node_modules is invalid, it points out of the filesystem root").
`build:selfhost` koristi stariji `webpack` bundler (`next build --webpack`),
koji nema taj problem — isti obrazac koji `dev` komanda već koristi. Obična
`build` komanda ostaje netaknuta jer je Vercel-u ne treba i tamo radi dobro.

`build:selfhost` takođe uključuje `NEXT_TEST_WASM=1` i `next.config.ts` ima
`experimental.cpus: 2` — oboje su već u repozitorijumu, ne treba ih ručno
dodavati, ali evo zašto postoje ako se ikad menjaju:

- Next-ov native Rust kompajler (`@next/swc-*`) ima ugrađen Tokio runtime koji
  sam detektuje broj CPU jezgara (`os.cpus()`) i pokuša da otvori toliko OS
  niti. Na deljenom/virtualizovanom hostingu `os.cpus()` vraća broj jezgara
  cele fizičke mašine (na unlimited.rs: 71+), ne stvarnu kvotu naloga — LVE
  limit procesa/niti to odbija i build puca sa `OS can't spawn worker thread:
  Resource temporarily unavailable (os error 11)`. `NEXT_TEST_WASM=1` forsira
  WASM verziju SWC-a (nedokumentovana ali stvarna zastavica, isti mehanizam
  koji Next.js koristi za WebContainer/StackBlitz okruženja) — WASM nema
  pristup pravom OS thread spawn-u pa problem nestaje. Samo build je sporiji,
  runtime (posle build-a) nije pogođen.
- `experimental.cpus: 2` ograničava Next-ov SOPSTVENI build-worker pool (broj
  paralelnih Node procesa za "Collecting page data"/"Generating static
  pages"), odvojeno od SWC-ovog internog Tokio pool-a iznad. I ovo je nekad
  bilo potrebno da spreči isti tip greške na drugom nivou, a usput sprečava i
  da previše paralelnih Prisma upita preplavi DB konekcioni pool.

- `--v8-pool-size=2` i `UV_THREADPOOL_SIZE=2` (u `build:selfhost`) hvataju
  slučaj koji prethodne dve mere NE pokrivaju. `NEXT_TEST_WASM` gasi SWC-ov
  Tokio pool, `experimental.cpus` Next-ov worker pool — ali **V8 pri startu
  svakog Node procesa otvara sopstveni thread pool po broju jezgara**, a
  `os.cpus()` na ovoj mašini vraća 71+. Jedan Node proces zato odmah traži
  ~71 nit + 4 libuv, i LVE ga odbije PRE nego što webpack uopšte krene —
  build padne za nekoliko sekundi sa `node[PID]: pthread_create: Resource
  temporarily unavailable` i izlaznim kodom 134 (SIGABRT).
  Prepoznaje se po tome što pada ODMAH, bez ijedne linije o build-u, i što
  čišćenje procesa ne pomaže.

**`ulimit -u` ovde laže** — prijavljuje `unlimited` jer kvotu nameće LVE na
nivou kernela, nevidljivo za `ulimit`. Stvarne brojke i probijanja limita
vide se u `cPanel → Resource Usage` („Entry Processes", „Number of Processes").

Ako i posle svega ovoga vidiš `OS can't spawn worker thread`, proveri
stvarni limit naloga u cPanel Resource Usage (ne kroz `ulimit`).

Ako TypeScript proveru tokom build-a preskoči stvarna greška (npr. posle
brisanja `node_modules` build i dalje ne prijavljuje poznatu grešku u nekom
`scripts/*.ts` fajlu), razlog je `tsconfig.json`-ov `incremental: true` keš
(`tsconfig.tsbuildinfo`) koji preživljava brisanje `node_modules` jer živi
van njega — obriši ga ručno pre ponovnog build-a: `rm -f tsconfig.tsbuildinfo`.

Ako menjaš `package.json` zavisnosti i ponovo instaliraš, obriši OBA mesta —
`node_modules` je simbolička veza, `rm -rf node_modules` briše samo vezu, ne
i stvarni sadržaj:

```bash
rm -rf ~/nodevenv/biznisportal.rs/<verzija>/lib/node_modules
rm -rf ~/biznisportal.rs/node_modules
rm -f tsconfig.tsbuildinfo
npm install --legacy-peer-deps --include=dev
```

Oba koraka na 8GB RAM-a i ovoj veličini projekta traju po nekoliko minuta.

## 5. Pokreni i testiraj NA PROBNOJ ADRESI

`Setup Node.js App → Restart`, pa otvori `https://test.biznisportal.rs`
(ili koju god probnu adresu si postavio) i proveri redom:

- [ ] Početna strana se učitava
- [ ] Profil firme se otvara (`/kompanije/<neki-slug>`)
- [ ] Prijava u portal radi
- [ ] `/sitemap.xml` vraća sadržaj
- [ ] Slika se prikazuje (znači R2 konekcija radi)
- [ ] Slanje forme (npr. recenzija) radi (znači baza upisuje)

Ako nešto ne radi, Passenger log (podešen u koraku 1, ili u `Setup Node.js
App → Edit → Passenger log file`) pokazuje tačnu grešku.

## 6. Zakazani poslovi (cron)

Skripta `scripts/cron-cpanel.sh` je već u repozitorijumu. Treba joj samo
tajna, van git-a:

```bash
echo "<prava-CRON_SECRET-vrednost>" > ~/biznisportal.rs/.cron-secret
chmod 600 ~/biznisportal.rs/.cron-secret
```

Zatim `cPanel → Cron Jobs` → dodaj svih 41 red iz tabele ispod. Svaki red je
zaseban cron unos (minut / sat / dan-u-mesecu / mesec / dan-u-nedelji /
komanda):

| Minut | Sat | Dan | Mesec | Dan u nedelji | Komanda |
|---|---|---|---|---|---|
| `40` | `2` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/auto-fill-descriptions >/dev/null 2>&1` |
| `45` | `3` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/awards-auto-progress >/dev/null 2>&1` |
| `0` | `7` | `*` | `*` | `1` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/business-radar-digest >/dev/null 2>&1` |
| `0` | `10` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/check-bulletin-ab-winners >/dev/null 2>&1` |
| `*/10` | `*` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/commercial-lifecycle >/dev/null 2>&1` |
| `25` | `*` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/cron-watchdog >/dev/null 2>&1` |
| `0` | `2` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/daily-ops >/dev/null 2>&1` |
| `0` | `3` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/expire-boosts >/dev/null 2>&1` |
| `0` | `*/12` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/generate-content-suggestions >/dev/null 2>&1` |
| `0` | `5` | `2` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/generate-industry-reports >/dev/null 2>&1` |
| `30` | `6` | `*` | `*` | `1` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/generate-matchmaking >/dev/null 2>&1` |
| `15` | `6` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/gsc-sync >/dev/null 2>&1` |
| `0` | `9` | `1` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/intelligence-digest >/dev/null 2>&1` |
| `50` | `4` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/magazine-auto-linker >/dev/null 2>&1` |
| `15` | `3` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/mark-events-held >/dev/null 2>&1` |
| `0` | `3` | `1` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/monthly-industry-reports >/dev/null 2>&1` |
| `0` | `9` | `3` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/monthly-traffic-report >/dev/null 2>&1` |
| `30` | `9` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/plan-expiring >/dev/null 2>&1` |
| `45` | `*` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/presence-cleanup >/dev/null 2>&1` |
| `10` | `*` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/process-bulletin-campaigns >/dev/null 2>&1` |
| `*/10` | `*` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/process-notifications >/dev/null 2>&1` |
| `30` | `5` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/process-review-ai >/dev/null 2>&1` |
| `20` | `*` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/publish-scheduled-articles >/dev/null 2>&1` |
| `0` | `6` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/recompute-health-score >/dev/null 2>&1` |
| `30` | `5` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/recompute-reputation >/dev/null 2>&1` |
| `30` | `6` | `*` | `*` | `1` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/refresh-business-radar >/dev/null 2>&1` |
| `30` | `3` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/refresh-embeddings >/dev/null 2>&1` |
| `0` | `5` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/refresh-quality-scores >/dev/null 2>&1` |
| `0` | `4` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/refresh-trust-scores >/dev/null 2>&1` |
| `5` | `0` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/reset-api-counters >/dev/null 2>&1` |
| `0` | `0` | `1` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/reset-lead-exchange-monthly >/dev/null 2>&1` |
| `30` | `4` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/response-time-stats >/dev/null 2>&1` |
| `0` | `9` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/review-flywheel >/dev/null 2>&1` |
| `0` | `6` | `1` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/reviewer-monthly-awards >/dev/null 2>&1` |
| `0` | `8` | `*` | `*` | `1` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/reviews-flywheel >/dev/null 2>&1` |
| `0` | `9` | `*` | `*` | `1` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/sales-digest >/dev/null 2>&1` |
| `0` | `7` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/saved-search-digest >/dev/null 2>&1` |
| `30` | `8` | `*` | `*` | `1` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/saved-search-digest-weekly >/dev/null 2>&1` |
| `15` | `2` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/self-serve-budget-sweep >/dev/null 2>&1` |
| `0` | `10` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/unpaid-invoice-reminder >/dev/null 2>&1` |
| `0` | `*` | `*` | `*` | `*` | `bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/webhook-retry >/dev/null 2>&1` |

Ako cPanel dozvoljava uvoz iz fajla, isti podaci su i u ovoj tabeli — inače
se ručno unose, red po red (traje, ali je jednokratno).

Proveri jedan ručno pre nego što veruješ rasporedu:

```bash
SITE_URL="https://test.biznisportal.rs" bash ~/biznisportal.rs/scripts/cron-cpanel.sh /api/cron/presence-cleanup
# očekuje se: 200  /api/cron/presence-cleanup
```

## 7. Probaj sve, danima ako treba

Ne žuri korak 8. Ostavi probnu adresu da radi paralelno sa živim Vercel
sajtom nekoliko dana. Proveri:

- Da li se cron poslovi pale po rasporedu (uporedi sa `CronRun` tabelom u bazi)
- Da li mejlovi stižu (recenzije, obaveštenja)
- Da li slike rade i otpremaju se
- Ponašanje pod stvarnim saobraćajem — 8GB RAM bi trebalo da je dovoljno, ali
  se to potvrđuje jedino praćenjem `Resource Usage` u cPanel-u par dana

## 8. Prebacivanje domena — tek kad je sve zeleno

1. U cPanel-u, promeni `Application URL` za Node.js App na glavni domen
   (`biznisportal.rs` i `www.biznisportal.rs`), ili dodaj glavni domen kao
   dodatni Application URL ako panel to dozvoljava.
2. Ažuriraj `NEXT_PUBLIC_SITE_URL` i `SITE_URL` na `https://www.biznisportal.rs`,
   restartuj aplikaciju.
3. Podesi SSL (cPanel AutoSSL, obično automatski za dodate domene).
4. Promeni DNS A/CNAME zapis domena da pokazuje na IP ovog servera (kod
   registratora domena, ili u cPanel Zone Editor-u ako DNS već vodi tuda).
5. Sačekaj propagaciju (do 24h, obično brže) i proveri da glavni domen
   stvarno služi sa novog servera: `curl -sI https://www.biznisportal.rs`
   treba da NE pokazuje Vercel-ove `x-vercel-*` zaglavlja.
6. Tek sada — obriši/pauziraj Vercel projekat, ili ga ostavi ugašen kao
   rezervu par nedelja pre potpunog gašenja.

## Ako nešto pođe naopako

DNS se u svakom trenutku može vratiti na Vercel (sajt je tamo i dalje netaknut
dok ne uradiš korak 8.6) — to je razlog zašto se ništa ne briše na Vercelu dok
selidba nije potvrđeno stabilna danima na probnoj adresi.
