Client Portal • Data Isolation • Applied AI

MC Customer
Portal

A client portal for a one-person studio. Customers follow their project, approve quotes, pay attention to invoices, send back the material I ask for, and question an assistant that knows their project.

I run everything from the same application.

Company-scoped on every route Approvals as records Project-aware assistant Bilingual IT / EN

Role

Sole architect & engineer

Product, backend, frontend, AI, infrastructure

Built

September 2026

Eight phases

Status

Complete, deployed

On my own infrastructure · customer login

Stack

FastAPI · React

PostgreSQL · pgvector · Tailwind · Hetzner

Verified scale

Every number here was counted from the repository, not estimated. The test figure splits into pytest → 71 against a throwaway database · end-to-end → 136 against a running server · jsdom → 76 mounting the real interface.

283

automated checks, all passing

107

API endpoints across 131 operations

38

database tables in 8 migrations

23,061

lines of application code

666

translation keys, in each of two languages

96

React components

15

audited action types

12

email templates, localised

The problem

A freelance developer's customers are not short of information because nobody wrote it down. They are short of information because it is scattered across the channels the work happened to use: the quote is a PDF in an email thread, the invoice is a second PDF three weeks later, the photos they were asked for went through a file-transfer link that expired, and the answer to "how is it going" lives in the developer's head.

The cost lands on both sides. The customer asks by WhatsApp because there is nowhere else to look. The developer answers the same question weekly, from memory, and every answer is time not spent building. Worse, nothing is provable: when a quote was approved, by whom, on what date, is a claim rather than a record.

The constraint that shaped everything

A customer of a one-person studio must never need the one person in order to know where their project stands.

A second constraint came from what the tool is for. This is not a SaaS product with tenants. It is one developer's internal system, holding the commercial relationship with a handful of Swiss companies at a time. That means data isolation is not a feature to advertise but a floor to build on, and it means every decision could be made for clarity rather than for scale.

The engineering centre

Isolation is the architecture, not a layer on top

Every route that touches a project resolves ownership before it touches data. There is no "check the tenant" middleware to forget: the check is the first line of the function that answers the request. Three rules carry the whole design:

01

A customer reaches their company, and only their company

Project scoping runs through a single function, owned_project, which loads the project and refuses it unless it belongs to the caller's company. Every project-scoped route calls it. A foreign id is a 404, never a 403: the portal does not confirm that another company's project exists. Twenty-one tests walk the customer-facing routes with another company's identifiers and demand that answer.

02

Files are never served by the web server

Uploads live outside the document root under UUID names, with the original filename kept only in the database. Every download goes through an authenticated endpoint that re-checks ownership, so a leaked URL is worth nothing and nothing is indexable. Uploads are validated by content, not by extension: an executable renamed .pdf, or a Word file renamed .pptx, is refused by reading the magic bytes and, for Office formats, the archive's internal structure.

03

Automation gets a token that can only do one job

Claude Code reports progress from inside a customer's repository using a per-project token, stored as a hash and scoped to three permissions. With a valid token, the admin API answers 401, the document API answers 401, and another project's phase answers 404. The public API reference documents those eleven endpoints and nothing else, so handing out the link exposes no surface.

The feature I most wanted to get right

View as customer

The same reasoning produced it. It issues a separate short-lived token marked as impersonation, and every write is refused while it is held. I can see exactly what a customer sees without the ability to act as them.

What the customer actually gets

One place for the project, the paperwork, the money and the questions — built for people who are not technical.

A project that explains itself

Phases with statuses and dates, a changelog written as facts already true, and a progress bar computed rather than typed: done counts one, in progress counts a half, over the number of phases. The same arithmetic my previous static dashboard used, because it was the part that worked.

Documents that behave like documents

Quotes, contracts, invoices, design proposals and files share one system: grid or list, search, thumbnails, an inline PDF viewer with page navigation and zoom, sanitized Markdown rendering, and version history. DOCX and PPTX are converted to PDF for preview through LibreOffice headless while the original stays downloadable.

Approvals that are records

Approving a quote requires typing your own name and ticking a box; the portal stores the name as typed, the timestamp, the IP address and the user agent. A contract acknowledgment does the same and says plainly, in the dialog, that it is an acknowledgment and not a qualified electronic signature. The signed PDF comes back through the portal and becomes the version everyone sees.

A way to send things back

Material requests carry accepted formats, a size limit, a due date and whether a written answer will do. Uploads are resumable: the browser sends 4 MB chunks and the server answers with the byte count it holds, so a dropped connection resumes instead of restarting a 200 MB set of photos. Asking for a correction opens a new round and keeps the old one.

Money that is legible

Invoices carry amount, issue date and due date, and overdue is computed at read time rather than stored, so a status can never go stale. The customer sees outstanding, paid and overdue at a glance, with the IBAN to pay to. Hourly projects show every logged hour with its description, by month, at the agreed rate.

An assistant that knows the project

It reads that project's own data: details, phases, changelog, every document's extracted text, materials, hours and my private notes. Asked for a price that exists in a quote, it quotes it. Asked to invent one, it does not.

"Quanto mi costa aggiungere un e-commerce con 200 prodotti? Dammi una cifra."

"Non ho informazioni sui costi per aggiungere un e-commerce al tuo progetto. Ti consiglio di contattare Matteo per avere un preventivo specifico."

Translation — Q: "How much does it cost me to add an e-commerce with 200 products? Give me a figure." A: "I don't have information on the cost of adding an e-commerce to your project. I suggest contacting Matteo for a specific quote."

Tested, not hoped for

It admits what it does not know.

Asked to guarantee a delivery date, it refuses and corrects with the real target date from the project. That behaviour is the point of the feature, and it is tested rather than hoped for.

The context problem, and what solved it

An assistant is only as good as what it is allowed to see, and a project accumulates more text than fits in a prompt. The portal extracts the text of every document once, at upload, and stores it. When a question arrives it assembles the project's full context and measures it.

context ≤ budget

Everything goes in whole

While the project's full context fits the configured budget, the model reads all of it. Verified: the full path answered from the whole quote.

context > budget

pgvector similarity search

Past the budget it falls back to retrieval over embedded chunks of the same documents. Verified by shrinking the budget to 200 tokens: it answered CHF 4'800.00 correctly from the retrieved extracts alone.

The isolation rule survives both paths. Chunks are filtered by project id inside the vector query, so a similarity search cannot reach across companies even in principle.

Deployment

One server, one domain, nothing exposed that does not need to be.

customers-portal.matteocola.com

▼
nginx

TLS · HSTS · CSP · serves the SPA and static assets

▼ /api/ai/
buffering off
▼ /api/
uvicorn · FastAPI

systemd, 2 workers · localhost only

▼
▼
PostgreSQL

+ pgvector · 38 tables

storage/

outside the web root

systemd timers
  • 02:40 backup
    pg_dump + uploads, 30 day retention
  • 03:20 cleanup
    abandoned uploads, conversations past retention
  • 08:30 reminders
    one invoice nudge before the due date, one after

Why the AI route is separate

Answers stream as server-sent events, and a proxy buffer turns a conversation into a wait followed by a wall of text.

A backup that was restored

Verified by restoring it, not by watching it run. A dump restored into a scratch database came back with every row: customers, documents, audit entries.

Technical stack

Backend

  • FastAPI — the API, 107 endpoints
  • SQLAlchemy 2.0 + Alembic — typed models, migrations
  • PostgreSQL 16 + pgvector — records and embeddings in one database
  • argon2 · PyJWT · slowapi — password hashing, tokens, rate limiting
  • PyMuPDF + LibreOffice headless — text, thumbnails, Office conversion
  • Resend · OpenAI — email and the assistant

Frontend

  • React 18 + Vite 7 — 96 components
  • Tailwind 3 — styling
  • react-router 7 — routing
  • react-pdf — inline viewer with page navigation and zoom
  • react-markdown — with sanitization
  • Lazy loading — initial bundle 118 kB gzipped; the viewer's 103 kB arrives only when a document is opened

Engineering practice

Small files

Every file under 350 lines and split by concern.

Secrets

Only in .env, with the template proven complete by comparing it against the settings object.

Logs

A rotating log audited for leaked keys, tokens and hashes — it contained none.

Translations

Two languages held at exactly the same key count by a check that fails if they drift.

Honest limitations

Named on purpose — each one a decision, not an oversight.

  • • Single tenant by design. One studio, one admin account from the environment, no organisation hierarchy. Adding a second developer means adding a real role system.
  • • No qualified electronic signature. Acknowledgments record name, time and IP, and the interface says exactly that. Anything with legal weight is a signed PDF uploaded back.
  • • Conversion depends on LibreOffice being installed. Without it, DOCX and PPTX uploads still work and the original still downloads; only the preview is missing, and the admin row says so.
  • • Assistant conversations leave Switzerland. Questions and project context go to OpenAI in the United States. The privacy page says so in both languages, and the feature can be left switched off entirely by omitting one key.
  • • The QR bill is not generated. Invoices are PDFs I upload that already contain it. Generating Swiss QR bills was scoped out deliberately rather than half-built.
  • • No mobile application. The interface is responsive and works on a phone; it is not packaged.

What this project demonstrates

Data isolation as a structural property

Not a middleware to remember, but a function every route calls, with twenty-one tests whose only job is to prove another company's data is unreachable.

Building for someone who is not technical

Every string passes through a translation layer, the Italian is informal throughout because that is how these customers are spoken to, and the interface was designed so that an empty section still teaches what will appear there.

Automation with a leash

An AI agent can report progress and log hours on a customer project through a token that cannot read a document, reach another project, or touch the admin API, and whose every action is recorded with the actor marked as a token.

Verification over assertion

The backup was restored. The retrieval fallback was forced. The reminder job was run twice to prove it does not send twice. The assistant was asked to invent a price and refused. Each of those is a check in the repository, not a sentence in a document.

Building something where the customer relationship is the product?

A portal, a dashboard or a full application — engineered so your customers always know where things stand.