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.
Sole architect & engineer
Product, backend, frontend, AI, infrastructure
September 2026
Eight phases
FastAPI · React
PostgreSQL · pgvector · Tailwind · Hetzner
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.
automated checks, all passing
API endpoints across 131 operations
database tables in 8 migrations
lines of application code
translation keys, in each of two languages
React components
audited action types
email templates, localised
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.
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:
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.
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.
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
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.
One place for the project, the paperwork, the money and the questions — built for people who are not technical.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
One server, one domain, nothing exposed that does not need to be.
customers-portal.matteocola.com
TLS · HSTS · CSP · serves the SPA and static assets
systemd, 2 workers · localhost only
+ pgvector · 38 tables
outside the web root
Answers stream as server-sent events, and a proxy buffer turns a conversation into a wait followed by a wall of text.
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.
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.
Named on purpose — each one a decision, not an oversight.
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.
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.
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.
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.
A portal, a dashboard or a full application — engineered so your customers always know where things stand.