Poslovna automatizacija
n8nPostgresAutomatizacijaIntegracijeOutbox obrazacInženjering pouzdanosti

Pouzdane integracije s n8n i Postgresom: tablice reda, outbox obrazac i isporuka „točno-jednom-ish“

AO
Adrijan Omićević
·16 min čitanja

# Što ćete izgraditi i zašto je to važno#

Ako vaš proizvod emitira događaje, a n8n ih pretvara u akcije, vaša je pouzdanost jaka onoliko koliko je jak most između ta dva svijeta. Izravno pozivanje n8n webhookova iz aplikacijskog koda često zakazuje na načine koje je teško sanirati: timeouti, nedostupnost n8n-a, prolazni mrežni problemi i duple isporuke.

Ovaj vodič prikazuje praktičan obrazac za pouzdane integracije korištenjem Postgresa kao outboxa i reda, uz n8n kao worker. Cilj su točno-jednom-ish ishodi: isporučiti svaki događaj barem jednom, ali osigurati da se sporedni efekti dogode jednom kroz idempotentnost i deduplikaciju.

Implementirat ćete:

  • outbox tablicu koja sprema događaje u istoj transakciji baze kao i vašu poslovnu promjenu
  • mehanizam reda/preuzimanja (claiming) kako bi n8n mogao sigurno paralelno obrađivati događaje
  • semantiku ponovnih pokušaja (retry), backoff i dead-letter tok za “otrovne” poruke
  • upite za monitoring i operativne zaštitne ograde za produkciju

Za dodatne obrasce i primjere s managed Postgres okruženjima, pogledajte i n8n + Supabase/Postgres obrasci automatizacije. Za rukovanje greškama unutar n8n-a, pogledajte n8n rukovanje greškama, retries i alerting.

# Pregled arhitekture: Postgres kao izvor istine#

Osnovna ideja: vaša aplikacija najprije upisuje događaje u Postgres, a zatim ih n8n čita i obrađuje. Time Postgres postaje trajna (durable) granica sustava.

Pouzdan tok izgleda ovako:

  1. 1
    Aplikacija izvrši poslovnu transakciju i u istoj transakciji upiše redak u outbox.
  2. 2
    n8n periodično poll-a (ili je okinut) kako bi preuzeo pending retke iz outboxa.
  3. 3
    n8n obradi svaki događaj, upiše rezultate, zatim označi outbox redak kao dovršen.
  4. 4
    Ako obrada ne uspije, redak ostaje pending i ponovno se pokušava s backoffom dok ne uspije ili se ne prebaci u dead-letter.

Zašto ovo radi:

  • Postgres već pruža trajnu pohranu i transakcijska jamstva.
  • Dobivate mogućnost ponovnog izvođenja (replayability) jer čuvate događaje dok ne dobijete potvrdu da su obrađeni.
  • O pouzdanosti možete razmišljati kroz mjerljive vrijednosti poput dubine reda i starosti događaja.

🎯 Ključna poruka: Zapišite namjeru u Postgres prije kontaktiranja bilo čega eksternog. Pouzdanost počinje trajnim upisom koji vi kontrolirate.

# Preduvjeti i kada ovaj obrazac ima smisla#

Ovaj obrazac najbolje odgovara kada:

  • već koristite Postgres kao primarnu bazu ili možete podići zaseban Postgres za integracijske događaje
  • vaši workflowi mogu tolerirati latenciju na razini sekundi ako koristite polling
  • trebate snažnu auditabilnost i mogućnost ponovnog izvođenja događaja

Možda ne odgovara kada:

  • trebate end-to-end latenciju ispod 100 ms
  • ne možete tolerirati opterećenje pollinga i ne kontrolirate obrasce pristupa bazi
  • volumen događaja je toliko velik da je prikladnija streaming platforma

Praktični pragovi:

  • Outboxi temeljeni na pollingu često podnose stotine do niske tisuće događaja po minuti na skromnoj Postgres instanci uz dobre indekse i batch preuzimanje.
  • Ako redovito prelazite desetke tisuća događaja po minuti, razmislite o zasebnom queue sustavu ili log-based pipelineu. I dalje možete zadržati outbox kao izvor istine, ali ga konzumirati specijaliziranim workerom.

# Model podataka: Outbox i opcionalno praćenje isporuke#

Trebaju vam dva koncepta:

  • Outbox: što treba obraditi
  • Ključevi idempotentnosti i deduplikacije: kako osigurati točno-jednom-ish ishode

Praktična shema za mnoge proizvode je jedna tablica. Za strožu idempotentnost dodajte drugu tablicu po konzumentu, ali mnogim timovima je dovoljno jedan outbox uz idempotentne upise nizvodno.

Shema outbox tablice#

Outbox sprema metapodatke događaja, payload i stanje obrade. Koristite jsonb radi fleksibilnosti, ali bitna polja indeksirajte.

SQL
create table if not exists integration_outbox (
  id bigserial primary key,
  event_type text not null,
  aggregate_type text not null,
  aggregate_id text not null,
  event_key text not null, -- deterministic idempotency key
  payload jsonb not null,
 
  status text not null default 'pending', -- pending, processing, done, failed
  attempts int not null default 0,
  available_at timestamptz not null default now(),
  locked_at timestamptz,
  locked_by text,
 
  last_error text,
  created_at timestamptz not null default now(),
  processed_at timestamptz
);
 
create unique index if not exists integration_outbox_event_key_uq
  on integration_outbox (event_key);
 
create index if not exists integration_outbox_pending_idx
  on integration_outbox (status, available_at, created_at);
 
create index if not exists integration_outbox_aggregate_idx
  on integration_outbox (aggregate_type, aggregate_id, created_at);

Napomene dizajna:

  • event_key je srce točno-jednom-ish ponašanja. Neka bude determinističan, ne nasumičan.
  • available_at omogućuje backoff bez potrebe za zasebnim schedulerom.
  • locked_at i locked_by omogućuju sigurno preuzimanje i paralelne workere.

ℹ️ Napomena: status = processing je opcionalan ako koristite jednu SQL naredbu koja i preuzima i vraća retke. Eksplicitno stanje pomaže u debugiranju i dashboardima.

Što bi event_key trebao biti?#

Koristite stabilan ključ koji predstavlja sporedni efekt koji želite da se dogodi jednom. Primjeri:

  • order.paid:orderId:paymentId
  • invoice.sent:invoiceId:version
  • crm.upsert:customerId:updatedAtEpoch

Ako koristite nasumičan UUID, i dalje možete imati isporuku barem jednom (at-least-once), ali gubite deduplikaciju kod dvostrukih objava iste namjere.

Opcije sheme i kompromisi#

OpcijaPrednostiNedostaciKada koristiti
Jedna outbox tablica s jedinstvenim event_keyJednostavno, lako za operacije, radi za većinu timovaTraži pažljiv dizajn event_keyZadani izbor
Outbox + zasebna integration_deliveries po ciljnom sustavuPraćenje po konzumentu, lakše rutiranje prema više odredištaViše sheme i logikeViše workflowa konzumira isti događaj
Outbox + particioniranje po vremenuBrže čišćenje, bolje performanse u skaliViše operativnog poslaVeliki volumeni, dugo zadržavanje

# Upis u outbox: transakcijsko objavljivanje#

Poanta outboxa je atomarnost: poslovno stanje i namjera događaja moraju se commitati zajedno.

Pojednostavljeni pristup:

  1. 1
    Ažurirajte poslovne retke.
  2. 2
    Umetnite outbox redak s determinističkim event_key.
  3. 3
    Commit.

Evo Postgres primjera gdje plaćanje narudžbe emitira događaj:

SQL
begin;
 
update orders
set status = 'paid', paid_at = now()
where id = $1 and status = 'pending';
 
insert into integration_outbox (
  event_type, aggregate_type, aggregate_id, event_key, payload
) values (
  'order.paid',
  'order',
  $1::text,
  'order.paid:' || $1::text,
  jsonb_build_object(
    'orderId', $1,
    'paidAt', now()
  )
)
on conflict (event_key) do nothing;
 
commit;

Ovaj obrazac sprječava duplikate nastale ponovljenim pozivima ili retryjima na razini aplikacije. on conflict do nothing čini put objave idempotentnim.

⚠️ Upozorenje: Izbjegavajte upis u outbox u zasebnoj transakciji nakon poslovnog updatea. Ako druga transakcija padne, imate plaćene narudžbe bez događaja i prisiljeni ste na ručno usklađivanje.

# Konzumiranje outboxa s n8n: Poll, Claim, Process, Ack#

n8n nije message broker. Tretirajte ga kao workflow engine i worker, a Postgres neka odradi ulogu reda.

Robusna petlja konzumenta ima ove korake:

  1. 1
    Poll za dostupne događaje.
  2. 2
    Preuzmi batch koristeći row-level locking.
  3. 3
    Obradi svaki događaj.
  4. 4
    Na uspjeh označi kao done.
  5. 5
    Na neuspjeh zakaži retry s backoffom, zabilježi grešku i po potrebi prebaci u dead-letter.

Polling trigger: zašto je zadani izbor#

Polling je dosadan — i baš zato je pouzdan.

  • Ako je n8n down, Postgres zadržava događaje.
  • Ako workflow pukne usred izvođenja, događaji ostaju pending ili postanu ponovno preuzimljivi.
  • Možete skalirati pokretanjem više n8n workera, gdje svaki preuzima različite retke.

Latencija: uz interval pollinga od 10 sekundi, tipična end-to-end latencija je 10 do 20 sekundi za većinu događaja. Za mnoge poslovne workflowe poput CRM sinkronizacije, fakturiranja i notifikacija, to je prihvatljivo.

Sigurno preuzimanje redaka s FOR UPDATE SKIP LOCKED#

Ovo je ključ za konkurentnost.

Čest pristup je jedna SQL naredba koja:

  • odabere pending, dostupne retke
  • zaključa ih tako da ih drugi workeri preskoče
  • ažurira lock metapodatke
  • vrati preuzete retke
SQL
with cte as (
  select id
  from integration_outbox
  where status = 'pending'
    and available_at <= now()
  order by created_at
  limit 50
  for update skip locked
)
update integration_outbox o
set status = 'processing',
    locked_at = now(),
    locked_by = $1
from cte
where o.id = cte.id
returning o.*;

U n8n-u to tipično izvršite Postgres nodeom, a zatim iterirate kroz vraćene retke.

Operativni savjeti:

  • Krenite s limit 50 i prilagodite prema prosječnom vremenu obrade.
  • Neka jedan run workflowa bude kratak. Veliki batch-evi mogu premašiti n8n execution timeoutove ili memory limite.

💡 Savjet: Postavite locked_by na ime n8n instance plus ID workflowa. To čini “zapele” lockove dijagnosticiranima bez nagađanja koji je worker preuzeo redak.

Obrada i potvrda uspjeha#

Nakon što obradite događaj, ažurirajte outbox redak:

SQL
update integration_outbox
set status = 'done',
    processed_at = now(),
    locked_at = null,
    locked_by = null,
    last_error = null
where id = $1;

Ako vaš workflow okida sporedne efekte u sustavima trećih strana, gdje god je moguće spremite i idempotency ključeve. Primjerice, Stripe, Slack i mnogi CRM-ovi podržavaju external ID-jeve ili idempotency headere.

# Retry semantika: Backoff, jitter i dead letters#

Retryji su mjesto gdje većina dizajna “pouzdanih integracija” pada. Ili retryja preagresivno i preoptereti ovisnosti, ili skriva kvarove dok se kupci ne požale.

Praktična politika retryja:

  • Maks pokušaja: 10
  • Backoff: eksponencijalan s gornjom granicom, plus jitter
  • Dead-letter: nakon maksimuma označi kao failed i alertaj

Jednostavan raspored backoffa:

PokušajOdgoda
130 sekundi
22 minute
35 minuta
415 minuta
530 minuta
6+60 minuta

Ovo možete implementirati preko available_at.

Označavanje neuspjeha i zakazivanje sljedećeg retryja#

SQL
update integration_outbox
set status = 'pending',
    attempts = attempts + 1,
    available_at = now() + ($2::int || ' seconds')::interval,
    last_error = left($3, 2000),
    locked_at = null,
    locked_by = null
where id = $1;

Gdje je $2 odgoda u sekundama izračunata u n8n-u, na temelju broja pokušaja.

Dead-letter za “otrovne” poruke#

Kad attempts dosegne prag, označite kao failed:

SQL
update integration_outbox
set status = 'failed',
    processed_at = now(),
    locked_at = null,
    locked_by = null,
    last_error = left($2, 2000)
where id = $1;

Kasnije možete ponovno pokrenuti failed događaje tako da ih vratite na pending nakon što ispravite uzrok.

Za dublju n8n strategiju grešaka, uključujući alert routing i failure workflowe, koristite n8n rukovanje greškama, retries i alerting.

# Polling vs webhooks: kompromisi i hibridni dizajni#

Timovi često krenu s “samo pozovi n8n webhook”. Webhookovi su u redu, ali samo ako i dalje perzistirate događaje — inače spajate ispravnost poslovanja s isporukom webhooka.

Matrica odluke#

PristupPouzdanostLatencijaSloženostNajbolje za
Direktno iz aplikacije u n8n webhook (samo)NiskaVrlo niskaNiskaNe-kritične notifikacije
Outbox + polling konzumentVisokaSrednjaSrednjaVećina poslovno-kritičnih automatizacija
Outbox + webhook “nudge” + polling fallbackVrlo visokaNiskaVišaKritično + potrebe za niskom latencijom

Hibrid: Webhook “nudge” uz Postgres kao izvor istine#

Koristite Postgres outbox kao trajni zapis, a webhook samo da brzo “probudi” n8n.

Tok:

  1. 1
    Aplikacija u transakciji upiše outbox redak.
  2. 2
    Aplikacija pozove n8n webhook samo s event_key.
  3. 3
    n8n webhook pokrene workflow koji odmah preuzme iz Postgresa.

Ako webhook poziv ne uspije, polling petlja će i dalje pokupiti događaj.

Dobivate:

  • nisku latenciju kad je sve zdravo
  • trajni oporavak kad nije

⚠️ Upozorenje: Nemojte slati puni payload putem webhooka kao primarni izvor istine. Držite payload u Postgresu i neka ga n8n čita odande — inače vaš “replay” postaje nemoguć.

# Isporuka „točno-jednom-ish“: što možete, a što ne možete jamčiti#

Stroga isporuka točno jednom zahtijeva koordinirane transakcije kroz sustave, što nemate kada n8n zove API-je trećih strana. Ono što možete postići je:

  • isporuku barem jednom (at-least-once) od Postgresa do n8n-a
  • točno-jednom efekte kroz idempotentnost i deduplikaciju

Odakle dolaze duplikati#

Duplikati se najčešće događaju jer:

  • n8n napravi timeout nakon poziva API-ja treće strane pa se run označi kao failed, a zatim se retryja
  • worker se sruši usred izvođenja
  • mrežne greške uzrokuju dvosmislene ishode
  • ista poslovna namjera bude objavljena dvaput zbog retryja aplikacije

Vaše obrane:

  1. 1
    Jedinstveni event_key u outboxu sprječava duplu namjeru.
  2. 2
    Idempotentne operacije u odredišnom sustavu sprječavaju ponavljanje sporednih efekata.
  3. 3
    Lokalna “idempotency tablica” može spriječiti ponavljanja kada odredište nema idempotentnost.

Opcionalno: Lokalni idempotency ledger#

Ako odredišni sustav ne podržava idempotentnost, spremite ledger redak ključan po event_key i ciljnoj akciji. Koristite unique constraint da nametnete točno-jednom-ish ponašanje čak i kroz retryje.

SQL
create table if not exists integration_idempotency (
  id bigserial primary key,
  event_key text not null,
  action text not null,
  created_at timestamptz not null default now()
);
 
create unique index if not exists integration_idempotency_uq
  on integration_idempotency (event_key, action);

U n8n-u, prije vanjskog sporednog efekta pokušajte insert. Ako dođe do konflikta, preskočite.

# Izgradnja n8n workflowa: praktične smjernice po nodeovima#

Tipično implementirate ovo kao dva workflowa:

  1. 1
    Poller workflow koji preuzima događaje i obrađuje ih.
  2. 2
    Alert workflow koji nadzire kvarove i backlog.

Workflow 1: Poll i obrada#

Preporučeni nodeovi i koraci:

  1. 1
    Schedule Trigger svakih 10 do 30 sekundi.
  2. 2
    Postgres node: claim batch s CTE update returning rows.
  3. 3
    Split in Batches: obradi svaki redak.
  4. 4
    Switch po event_type: rutiranje prema handlerima.
  5. 5
    Za svaki handler:
    • pozovi vanjski API
    • upiši audit retke ako treba
    • označi outbox redak kao done
  6. 6
    Error putanja:
    • izračunaj backoff odgodu iz attempts
    • ažuriraj outbox redak s retry ili failed statusom

Neka svaki handler bude idempotentan. Ako ne možete učiniti third-party poziv idempotentnim, dodajte lokalni idempotency ledger.

Primjer izračuna backoffa#

Ovo je kompaktna funkcija koju možete implementirati u n8n Code nodeu. Neka bude kratka i deterministična.

JavaScript
const attempts = $json.attempts || 0;
 
const schedule = [30, 120, 300, 900, 1800, 3600];
const base = schedule[Math.min(attempts, schedule.length - 1)];
 
// jitter between 0 and 10 percent
const jitter = Math.floor(base * (Math.random() * 0.1));
 
return [{ delaySeconds: base + jitter }];

Koristite delaySeconds kao $2 parametar u queryju za retry update.

# Monitoring i alerting: što mjeriti u Postgresu#

Ako ne vidite rast backloga i zaglavljene retke, nemate pouzdane integracije. Monitoring je dio dizajna, ne dodatak.

Osnovne metrike#

Pratite ova četiri:

  1. 1
    Dubina reda: broj pending redaka.
  2. 2
    Starost najstarijeg pending zapisa: koliko najstariji pending čeka.
  3. 3
    Stopa neuspjeha: failed retci po satu.
  4. 4
    Pritisak retryja: distribucija attempts.

To se prevodi u alarme na koje se može djelovati.

Upiti za monitoring#

Dubina reda:

SQL
select count(*) as pending_count
from integration_outbox
where status = 'pending'
  and available_at <= now();

Starost najstarijeg pending zapisa:

SQL
select now() - min(created_at) as oldest_pending_age
from integration_outbox
where status = 'pending';

Neuspjesi u zadnja 24 sata po tipu događaja:

SQL
select event_type, count(*) as failed_count
from integration_outbox
where status = 'failed'
  and created_at >= now() - interval '24 hours'
group by event_type
order by failed_count desc;

Zaglavljeni processing lockovi, npr. stariji od 15 minuta:

SQL
select id, event_type, locked_by, locked_at, attempts
from integration_outbox
where status = 'processing'
  and locked_at < now() - interval '15 minutes'
order by locked_at asc;

Operativni SLO-ovi koje stvarno možete koristiti#

Odaberite pragove koji odgovaraju poslovnom utjecaju. Primjeri:

  • Alert ako je oldest_pending_age veći od 5 minuta za customer-facing notifikacije.
  • Alert ako je pending backlog veći od 1000 dulje od 10 minuta.
  • Alert ako postoji bilo koji failed redak za payment-related event_type.

💡 Savjet: Definirajte “SLO latencije automatizacije” poput P95 vrijeme od created_at do processed_at manje od 2 minute. Tada optimizirate prema brojci, ne prema osjećaju.

# Operativne smjernice: oporavak lockova, zadržavanje i performanse#

Oporavak zaglavljenih redaka#

Ako se n8n sruši, retci mogu ostati processing s “starim” locked_at. Dodajte periodični “reaper” korak, ili unutar poller workflowa ili kao zaseban job:

  • pronađi retke u processing gdje je locked_at stariji od timeouta
  • vrati ih na pending s available_at = now()
SQL
update integration_outbox
set status = 'pending',
    locked_at = null,
    locked_by = null,
    available_at = now()
where status = 'processing'
  and locked_at < now() - interval '15 minutes';

To pretvara crashove u retry događaje.

Retencija i čišćenje (pruning)#

Ako zadržavate svaki redak zauvijek, tablica raste i indeksi usporavaju. Većina timova drži:

  • done retke: 7 do 30 dana
  • failed retke: 30 do 90 dana, ovisno o audit zahtjevima

Brišite u malim batch-evima da izbjegnete teške lockove:

SQL
delete from integration_outbox
where status = 'done'
  and processed_at < now() - interval '30 days'
limit 5000;

Ako vaš Postgres ne dopušta delete ... limit, brišite kroz podupit koji odabire ID-jeve.

Indeksiranje i hotspotovi#

Najvažniji indeks je onaj za selekciju pending redaka. Ovaj vodič koristi:

  • (status, available_at, created_at) za “pending i dospjelo, najstarije prvo”
  • unique indeks na event_key

Ako vidite contention, tipična rješenja su:

  • smanjite batch size
  • pollajte češće s manjim batch-evima
  • izbjegavajte čitanje teškog payloada ako nije potrebno tako što ćete selektirati samo nužne stupce za rutiranje

Skaliranje n8n konzumenata#

Ovaj obrazac se horizontalno skalira:

  • pokrenite više n8n instanci
  • svaka preuzima retke koristeći skip locked
  • nije potrebna koordinacija

Budite realni oko konkurentnosti:

  • Ako jedan handler događaja zove API treće strane s limitom 10 zahtjeva u sekundi, maksimalni sigurni throughput je ograničen tim limitom, ne Postgresom.
  • Po potrebi koristite throttling u n8n-u specifičan po tipu događaja.

Za produkcijske automatizacije i pomoć pri implementaciji, pogledajte Samioda automation.

# Česte greške i kako ih izbjeći#

Greška 1: Tretiranje n8n-a kao reda#

n8n sprema povijest izvršavanja, ne trajni event log vašeg domenskog modela. Ako se oslonite na n8n kao jedinu kopiju događaja, gubite replayability i povećavate spregu.

Izbjegnite to tako da uvijek prvo spremite događaje u Postgres.

Greška 2: Nema idempotentnosti u nizvodnim sustavima#

Isporuka barem jednom bez idempotentnosti znači duple sporedne efekte. Tipični simptomi su dupli računi, duple Slack poruke ili ponovljeni CRM updatei koji neočekivano “preklapaju” polja.

Izbjegnite to s event_key, idempotency ključevima na odredištu i lokalnim idempotency ledgerom kada je potrebno.

Greška 3: Retryanje svega na isti način#

Nisu svi kvarovi prolazni. Primjerice, “invalid API key” se nikad neće popraviti retryjem i samo će pojačati šum.

Izbjegnite to klasifikacijom grešaka unutar n8n-a:

  • prolazne: retry s backoffom
  • trajne: odmah označi failed i alertaj

Greška 4: Nema monitoringa dok se ne pokvari#

Timovi često dodaju monitoring nakon prvog incidenta. Do tada ste izgubili vrijeme i povjerenje.

Izbjegnite to tako da od prvog dana isporučite barem alarme za backlog i starost najstarijeg pending zapisa.

# Ključne poruke#

  • Koristite Postgres outbox tablicu upisanu u istoj transakciji kao i poslovna promjena kako biste spriječili izgubljene događaje.
  • Preuzimajte događaje u batch-evima s FOR UPDATE SKIP LOCKED kako biste omogućili sigurne paralelne n8n workere bez dvostruke obrade.
  • Ciljajte točno-jednom-ish ishode kombinirajući isporuku barem jednom s determinističkom deduplikacijom putem event_key i idempotentnim nizvodnim akcijama.
  • Implementirajte retryje s backoffom koristeći available_at, a “otrovne” poruke prebacite u failed stanje uz jasne alarme.
  • Pratite dubinu reda, starost najstarijeg pending zapisa, zaglavljene lockove i broj failova te postavite alarme vođene SLO-ovima prije puštanja u produkciju.

# Zaključak#

Pouzdane integracije rijetko su rezultat jednog “pametnog” webhooka. Riječ je o dizajnu trajne granice, mogućnosti ponovnog izvođenja i operativne vidljivosti. Postgres vam daje transakcijska jamstva i observabilnost, a n8n fleksibilno izvršavanje workflowa. Zajedno, uz outbox i semantiku reda, možete isporučiti integracije koje preživljavaju nedostupnost, retryje i skaliranje.

Ako želite da se ovaj obrazac implementira end-to-end, uključujući modeliranje događaja, politike retryja, dashboarde i produkcijsko “očvršćivanje”, Samioda vam može pomoći da ga dizajnirate i izgradite uz n8n, Postgres i vaš stack. Krenite ovdje: https://samioda.com/en/automation.

FAQ

Share
A
Adrijan OmićevićOsnivač i senior developer

Osnivač i senior developer u Samiodi. 8+ godina iskustva u izradi React, Next.js, Flutter i n8n rješenja za klijente diljem Europe.

Više iz kategorije Poslovna automatizacija

Sve

Trebate pomoć s projektom?

Gradimo prilagođena rješenja koristeći tehnologije iz ovog članka. Senior tim, fiksne cijene.