A multi-user web application built to replace the Excel files of a small Greek insurance office: customers, vehicles and who owns them, policies and their renewals. Spring Boot and PostgreSQL, pages rendered on the server with Thymeleaf. The interface is in Greek, because the office is.
Important
All data in the screenshots and in the demo is synthetic. It comes from
a generator with a fixed seed
(DemoData): common
Greek first names and surnames put together at random, tax numbers (ΑΦΜ)
from the series 90000xxxx, mobiles from 6900000xxx, addresses at
example.com, plates whose number starts with 0 (real ones run from
1000 to 9999), and insurers named after Microsoft's sample companies. None
of it is a real customer, insurer or office. The repository is checked for
personal data and secrets before every push, with
gitleaks and rules of its own for
ΑΦΜ, phones, plates and VINs (.gitleaks.toml).
The office kept everything in Excel: one file of customers, one of vehicles and their policies. Two jobs mattered most (SPEC §1):
- Finding a record at the counter. A customer walks in with a plate, a tax number, a phone number, a name or a policy number. The clerk must find them at once, in one box, without first choosing what kind of value was typed.
- Not missing a renewal. Car insurance runs for six or twelve months, and every policy about to end is a phone call to make before it lapses.
Two or three clerks work on the office network, and one person from
outside. The application is made to run on a small server in the office,
with Docker Compose (deploy/), reached only through a VPN
(Tailscale).
The screenshots are made from the demo's synthetic data by
ReadmeScreenshots,
with Playwright, so they show the current pages; the pages and crops keep
tax and mobile numbers few. To make them again:
./mvnw test -Pbrowser -Dtest=ReadmeScreenshots.
It needs Docker with Compose, and nothing else:
git clone https://github.com/imertekis/insurance-office.git
cd insurance-office
docker compose -f compose.demo.yaml upThe first start builds the application from the sources (a few minutes), starts PostgreSQL and the application, fills the empty database with the synthetic data, and prints two accounts with random passwords:
DEMO: συνθετικά δεδομένα (synthetic data): 200 πελάτες, 236 οχήματα, <N> συμβόλαια.
http://127.0.0.1:8080, με τους λογαριασμούς (accounts):
ρόλος (role) όνομα κωδικός (password)
ΔΙΑΧΕΙΡΙΣΤΗΣ admin <random>
ΥΠΑΛΛΗΛΟΣ clerk <random>
The policies' dates are counted from the day of the fill, so their number (some 650) varies with that day; the customers and vehicles do not.
Open http://127.0.0.1:8080 and log in as clerk, or as admin, the
administrator, who can also delete. The passwords are printed only by the
start that made them. The data and the accounts stay from one up to the
next; to start over, with new data and new passwords:
docker compose -f compose.demo.yaml down -vAfter pulling a newer version, up --build builds it. The demo listens on
this machine only, shows a yellow DEMO strip on every page, and fills no
database but its own: it checks the name before Flyway touches anything
(Task 39b).
On Windows, the same commands work in PowerShell with Docker Desktop, in its
default Linux containers mode, from a clone or from GitHub's Download ZIP
(in the folder that holds compose.demo.yaml). The files keep their LF line
endings whatever Git's core.autocrlf says (.gitattributes), and the build
runs the Maven wrapper with sh, so it needs no executable bit.
For development, with JDK 21 and Docker: ./mvnw verify builds and runs
every test on a real PostgreSQL, and ./mvnw verify -Pbrowser adds the
browser tests (the first run downloads Chromium). The other commands are in
CLAUDE.md.
Java 21, Spring Boot 4.1, Spring Data JPA with Hibernate 7, PostgreSQL 18
(unaccent, pg_trgm), Flyway, Thymeleaf and Bootstrap 5.3 with a little
plain JavaScript (no Node build), MapStruct, Apache POI for the one-off
Excel migration; JUnit, Testcontainers and Playwright for Java.
One search box; accents, case and final sigma normalized twice, and a
test that keeps both sides equal. Regular expressions decide what was
typed (VIN, plate, ΑΦΜ, mobile, landline, policy number), and anything else
is free text, where Αλεξίου, αλεξιου and ΑΛΕΞΙΟΥ are the same. The
stored side is a generated column that PostgreSQL computes, through an
IMMUTABLE wrapper of unaccent and upper-casing in the pg_unicode_fast
collation, so writes that bypass JPA (the Excel import, manual SQL) fill it
too; a trigram index serves it. The typed side is normalized in Java. An
integration test sends accented, mixed-case and final-sigma input through
both and requires the same output. Ten digits starting with 21 are both a
landline and a policy number, so both are searched and the results grouped.
Code: TextNormalizationUtils,
V2__unaccent_wrapper.sql,
SearchService,
SearchNormalizationConsistencyTest.
Decision: CLAUDE.md, resolved conflicts 1 and 3;
DECISIONS §3, one
box and no type dropdown.
Greek plates. A standard Greek plate uses only the 14 letters that
Greek and Latin share (Α Β Ε Ζ Η Ι Κ Μ Ν Ο Ρ Τ Υ Χ), and clerks type them
with either keyboard. A plate is stored in capitals and without dashes, in
the alphabet it was typed in, and a second column maps those 14 letters to
Latin, for search, sorting and the unique index: ΑΒΕ-1234 and ABE1234
are one plate.
Greek-only letters stay Greek, so ΑΒΓ-1234 is not ABG-1234: special
plates keep them.
Code: TextNormalizationUtils.normalizePlate,
Vehicle.
Decision: CLAUDE.md, resolved conflict 5.
A unique violation is a message beside the field, not an error page.
The services check uniqueness before writing, but two clerks can save the
same ΑΦΜ at the same moment, and then the database's unique index refuses
the second. The controller catches the exception outside the transaction,
which has already rolled back, and shows the form again with what was typed
and a Greek message beside the field. Which index is which field is read
from the constraint name in PostgreSQL's error (SQLState 23505), never
from the message text, which changes with the server's language.
Code: UniqueConstraint,
FormErrors,
UniqueViolationFormTest.
Decision: Task 18.
Optimistic locking. Customers, vehicles and policies carry a
@Version, and a form sends back the version it was opened with. A save
over someone else's change is refused: the form comes back with a message
and the clerk's input, and nothing is overwritten silently. Task 7 asked for
HTTP 409 from the JSON API of the time; the forms that replaced that API
show the conflict in the page. One detail: with a column the database
generates, Hibernate 7.4's UPDATE … RETURNING reported a stale version as
"no natively generated values", a 500, so a small dialect turns RETURNING
off, and a test fails without it.
Code: PostgreSQLVersionCheckingDialect,
OptimisticLockingTest.
Decision: Task 7;
CLAUDE.md, resolved conflict 2.
An audit log from a JPA listener, in the same transaction. Every
insert, update and delete of a customer, vehicle, ownership, policy or
intermediary is written to audit_log as JSONB, old and new values by
column, with the user's id, by an entity listener on the transaction's own
connection: a change and its log entry commit or roll back together.
Deletes are real deletes, and a deleted row can be put back from its JSON.
Hibernate Envers was rejected: its own tables per entity, and no JSONB.
Code: AuditListener,
AuditListenerTest.
Decision: ARCHITECTURE §6.
Tests on a real PostgreSQL, and in a real browser. Every integration
test runs on PostgreSQL 18 in Testcontainers, never H2: generated columns,
unaccent, pg_trgm and the unique indexes are PostgreSQL's own. The
JavaScript (search suggestions, the guards on forms, owners' shares, the
date picker, the theme) is tested in headless Chromium with Playwright for
Java, in a Maven profile of its own, with no Node installed. Each part was
seen failing: 29 deliberate breakages of the JavaScript, one at a time, each
made a test of its part fail, except one, which is explained.
Code: TestcontainersConfiguration,
src/browser-test.
Decision: Task 24;
NOTES, browser tests.
Passwords, and failed logins that reveal nothing. One rule for every
password: 8 characters or more, a letter and a digit, spaces allowed, at
most 72 bytes because bcrypt reads no further, and never the username.
Accounts are made from the terminal, which asks for the password without
showing it, never on the command line. Five failed logins lock a username
for five minutes, counted per name as typed whether an account has it or
not: the page and the message are the same, and while a name is locked no
password is checked, so the answer takes the same time. The login page
never tells which accounts exist.
Code: PasswordPolicy,
LoginAttempts,
LoginLockoutTest.
Decision: Task 22.
I set the requirements and made every decision. The original documents in
docs/ were written with Google Antigravity. Claude Code drafted
most of the task specifications in TASKS from my
requirements; I reviewed them and answered its questions. What was accepted
or rejected is in DECISIONS. I wrote Task 29, the
session timeout, myself. OpenAI Codex ran the automated browser check for
Task 29 and wrote its entry in NOTES.
The code was written mostly by Claude Code, one task at a time, under the
rules of CLAUDE.md: only the task at hand, done only when the
whole test suite passes, and a question back to me whenever the documents
disagreed. The work was reviewed for correctness only: Google Antigravity
(agy) wrote REVIEW-01 to
REVIEW-05,
REVIEW-06-BINDING and
REVIEW-07 to REVIEW-10, and
REVIEW-06 came from Claude Code's own review feature
(ultrareview). The findings became tasks of their own, such as Task 18 from
REVIEW-06 and Task 24 from REVIEW-09, or open items in
NOTES.
The commit history shows the same order: a task's specification in a
Docs: commit, its implementation in an Implement Task … commit, and the
fixes a review asked for in Address REVIEW-… commits.
The documents in docs/ are in Greek, apart from the reviews:
SPEC (requirements), DATA_MODEL,
ARCHITECTURE, TASKS (the plan
and its decisions), DECISIONS,
NOTES (open questions, known limits, measurements),
REVIEW-* (in English) and
DEPLOYMENT. CLAUDE.md, in English,
holds the working rules and the decisions that override older wording.
MIT. All 161 third-party libraries, those of the tests and the
browser tests included, were checked once with license-maven-plugin 2.7.1
(add-third-party, not part of the build): Apache-2.0, MIT, BSD and EDL,
and EPL-2.0, LGPL-2.1 or GPL-2.0 with the Classpath Exception for libraries
used unmodified (Logback, JUnit, AspectJ, JNA, the Jakarta APIs), all
compatible with MIT; Bootstrap and flatpickr are MIT, Playwright is
Apache-2.0, and the Maven wrapper scripts keep their Apache-2.0 header.








