DEPLOYMENTS / BAJTICA
CONSTRUCTION SAASBajtica
A two-sided reporting SaaS for Slovenian construction companies. The site manager files updates from their phone in three taps; the investor watches phases advance, obligations land and photos stream in — inside a PWA that installs to the home screen without an account. Claude reads the contract PDF and drafts the phases, dates and obligations for the builder to approve.
CLIENT
Vertical B2B product
YEAR
2026
SYSTEMS
WEB · AI
STATUS
DEPLOYED
15
phases in the turnkey (SLO) system template
3
delivery channels — email, SMS, web push
0
passwords the customer needs to log in
50 MB
per-file cap enforced at the Storage bucket
5
user roles, all constrained by Postgres RLS
1
server secret path for the client portal
[ THE BRIEF ]
The challenge we walked into.
Construction sites communicate by phone calls, WhatsApp threads and paper — investors are perpetually one phone call behind, and site managers waste evenings retyping what happened for whoever asks next. The customer typically isn't tech-fluent and won't manage a password. We needed one system that does the work of every call, every 'when will it be done?' text and every 'what do you need from me?' email — with zero login friction for the client and near-zero typing for the site manager.
[ TOPOLOGY ]
How the system is wired.
// SYSTEM TOPOLOGY
- LAYER 01
Customer PWA
PASSWORDLESS
Firm App
SUPABASE AUTH
- LAYER 02
portal/session.ts
SINGLE CHOKE POINT
Server Actions
ZOD + RLS
- LAYER 03
Supabase Postgres
RLS + TRIGGERS
Supabase Storage
PHOTOS · DOCS · CONTRACTS
- LAYER 04
Claude API
CONTRACT + MESSAGES
Resend
EMAIL
BulkGate
SMS (EU)
Web Push
VAPID
- LAYER 05
Vercel Cron
DAILY · WEEKLY · 15-MIN
[ WHAT WE SHIPPED ]
Feature by feature.
Firm app + client portal, sharing state through RLS.
The firm side is a normal app (Supabase Auth, /projekti dashboard, admin + site-manager roles). The client portal at /portal is passwordless: the customer opens a link and lands inside their project. Both surfaces read the same tables, but only one — the firm side — can write. Row-Level Security helper functions (auth_company_id(), auth_can_access_project()) keep every query honest at the database, not in application code.
- ▸RLS on every table with company + project scoping
- ▸5 roles: admin, project manager, site manager, contractor, client
- ▸Portal reads gated by a single server function, not by RLS
- ▸Deferred FK invariants — no orphan phases, no half-created projects
[ APPROACH ]
How it came together.
01 — WHERE THE AI LIVES
Claude drafts. The human commits.
The ANTHROPIC_API_KEY is server-only and read exactly once, in src/lib/ai/client.ts. Every call to Claude — contract extraction, update message drafts — has a strict zod schema on the output, and every schema failure resolves to a safe fallback: either an empty extraction with a note asking the builder to fall back to the phase template, or a plain-language draft the manager still has to hit 'send' on. Model output is never load-bearing. The client can never receive a message that a human on the firm side didn't approve.
- ▸Structured output via zodOutputFormat helper
- ▸Confidence labels + literal citations on every extracted field
- ▸Refusal fallback: 'unclear' + start from template
- ▸Every AI cost tracked (anthropicCostCents + extraction_usage table)
02 — TRUST BOUNDARY
The client portal has no auth session.
The customer portal deliberately does not use Supabase Auth. The token link is exchanged for a signed cookie in portal/session.ts, and every downstream query goes through that one function — RLS is not the fence here, the code is. The firm side does the opposite: no server function, RLS all the way down. Splitting the trust boundary this way means a leaked customer link cannot escalate into an account, and a leaked staff account cannot see another company's data.
- ▸portal_link + share_link tokens hashed with PORTAL_TOKEN_SECRET
- ▸rate_limit_hit() throttles both login and token entry
- ▸auth_company_id() + auth_can_access_project() enforce staff-side scope
- ▸Storage reads go through signedUrlFor(), which also logs the access
03 — FAILING SAFE
Every external dependency has a defined failure mode.
No Resend key → email is logged, not sent. No BulkGate key → same, for SMS. No Anthropic key → AI modules quietly disappear from the UI. Claude refuses → the contract extraction returns 'unclear' and the builder sees a Slovene message telling them to start from the template. A stuck extraction is retried by a 15-minute cron. A push subscription that returns 410 Gone is deleted on discovery. The pipeline runs to completion whether or not the outside world cooperates.
04 — DEPLOYMENT SURFACE
Five migrations, no dashboard clicking.
The entire schema — 25+ tables, all enums, every RLS policy, all four Storage buckets, and the seeded 15-phase 'Turnkey (SLO)' template — lives in Supabase migrations. A fresh cloud project goes from empty to production-ready with three commands: supabase link, supabase db push, npm run verify:schema. That verify step doesn't just list tables; it actually attempts an unauthenticated read on every RLS-protected table and asserts it fails.
- ▸5 migrations = full schema, verified with a script, not by hand
- ▸verify:schema actively probes RLS instead of trusting the setup
- ▸Feature flags per module × plan enforced in a single matrix
- ▸EU-hosted stack throughout: Supabase EU + BulkGate CZ + Resend EU
[ DELIVERY ]
What the build looked like.
01 ▊
[ TECH STACK ]
What’s under the hood.
TAP FOR ROLEHOVER FOR ROLE
[ IMPACT ]
What changed.
Site managers file phase updates from their phone in three taps. Customers watch construction happen — phases advancing, photos landing, obligations arriving — in a PWA they install without ever creating an account. Claude reads the contract and drafts the whole plan for the builder to approve. Every AI decision is auditable and every external service can be down without stopping the pipeline. What was a stream of WhatsApp messages and evening phone calls is now one system that everyone reads and only the builder writes to.