Assembly
Turning documents into one report
A web app that assembles formal client reports.



The problem
The team at a consulting firm assembled every formal client report by hand: a cover page, then appendix dividers, then the documents for each appendix, merged into one PDF. The same nine report structures, over and over, with different content each time.
Done by hand, it was slow and easy to get wrong: pages out of order, numbering that restarted halfway through, an appendix missing. Every project deliverable paid that cost again.
What I built
A web app that turns report assembly into a short flow:
Set up template → Publish version → Create study → Add files → Preview → Export
Each kind of report is a template: its sections, a cover for each, how sections are lettered, and what the footers say. Publishing a template freezes it as a version. After that, someone creates a study from a version, drops PDFs or Word documents into its sections, picks the pages they need, previews the whole thing, and exports one PDF with continuous page numbering, or a ZIP with a folder per section.
How it works
Three services: a Next.js front end, a FastAPI back end, and Gotenberg, a document-conversion service that runs LibreOffice behind an HTTP API. The work happens in three flows, at three different times:
- Publish, once per template change. The template's covers and footer rules are copied into storage the version owns and fingerprinted. A study is pinned to one version, so editing the template never changes a study already in progress.
- Upload, for every document. The original is stored as-is. Word files go to Gotenberg and come back as PDF. Choosing pages, and cleaning old page numbers and footers, are steps the user takes, with a preview first.
- Build, on every preview or export. The API gathers the study's files in order, adds the version's covers, merges them, stamps the footers, adds a bookmark per section and compresses the result with Ghostscript.
The highlighted path follows a document from upload to the finished report.
A few of the decisions:
- Rebuild from parts, cache by content. A report is always built from its parts, never edited in place. Previews are cached under a hash of every input: the version, the covers, the footers, each document and its chosen pages. Change any of them and the key changes, so a stale preview can't be served. Exports skip the cache and are built fresh.
- Versions, not live templates. A study half-built against last month's template shouldn't change because someone edited the template today. Freezing a version is a copy and a hash, and it made template editing safe.
- One set of footer rules, checked twice. A footer can be set for every page, every cover, one section, one study, or one section of one study, and the most specific setting wins. The rules run in Python when stamping and in TypeScript for the live preview in the editor; both are tested against the same file of cases, so the two can't drift apart.
- Conversion in its own container, pinned. Running LibreOffice inside the API would have bloated it and tied the two together. Gotenberg is pinned to one version, because a LibreOffice update can shift how a document renders.
- SQLite, on purpose. It's a single-team internal tool. SQLite meant no extra service to run, and SQLAlchemy keeps the move to Postgres a small change if it's ever needed.
What was hard
Footers. Page numbering sounds trivial until the source documents already have their own, so cleaning exists as its own step, with a preview. Then covers needed different footers from pages, one appendix needed different ones from the rest, and one study needed to override its template. The fix was to name five levels and one rule: the most specific setting wins.
What I learned
Assembly does one narrow job for one team, and it removed a task people repeated on every project. Understanding that job precisely was the real work; the build was small.