Success story · Healthcare · Bookings

The clinic booking app that forgot every appointment when you refreshed the page.

An agency built it on Base44 for a chain of four clinics. Staff loved it for three weeks. Then a receptionist closed the tab. The data model existed only in the browser's memory; the 'database' was a demo template that reloaded on start.

Healthcare, 4 clinicsBuilt with Base44Agency owner (built it for a client)Readiness 1.4 → 3.748 hours
Success story · Healthcare · Bookings
0rows persisted before→nightlyrestore-tested backups
The clinic booking app that forgot every appointment when you refreshed the page.
prodreadyBuilt with Base44 · production-ready in 48 hours
0
rows persisted before
13
tables a test could wipe
42
foreign keys after
nightly
restore-tested backups

Sound familiar?

You build apps for clients. The last one went well: a booking tool on Base44 for a business with several locations, delivered in a fortnight, and the client's staff actually use it. You have a maintenance retainer. What you don't have is an answer to a question nobody has asked yet: where, exactly, is the data. The prompt said "save appointments" and the app saves appointments, on screen, every time. You have not opened the generated backend since the demo. You would rather not be the person who finds out what's in it during a phone call with the client.

What they sent us

The customer was a two-person agency. They had built a booking and rota tool on Base44 for a chain of four outpatient clinics: receptionists book, practitioners see their day, the manager sees all four sites. For three weeks the client was happy, the staff were happy, and the agency invoiced the second milestone.

On day 21 a receptionist at the second clinic closed the tab. When she reopened it, the day view showed fourteen appointments with placeholder names, all dated the day of the original demo. Every booking made in three weeks was gone. The other three clinics were still fine, because nobody there had refreshed yet.

The agency owner opened the code to work out what had happened. That is the moment in the quote at the top of this page. He sent the repository to prodready the same evening; Discovery ran overnight.

Discovery scanned 19 dimensions and logged 118 findings: 5 critical, 28 high, 64 medium, 21 low. The critical ones were all the same finding in different clothes. The UI held patients, practitioners, rooms, appointments and the waitlist as JavaScript arrays in browser state, seeded from a file the tool had generated on day one. The API had two endpoints the UI ever called: login, and a bootstrap call that returned a demo template. The same JSON, every time, for every clinic.

A database existed. Thirteen tables, correctly named, with sensible columns. They were created by create_all every time the server started, and nothing ever wrote to them. Rows persisted across three weeks of use at four clinics: 0. Our estimate of what the staff believed they had booked in those three weeks: about 3,400 appointments.

The test setup was the part that made our reviewer put her coffee down. The pytest configuration dropped every table on whatever DATABASE_URL was exported in the shell. Point it at a hosted database by accident and it wipes 13 tables without asking. The two "migration revisions" in the repository were both snapshots of the same models; running a migration check would have tried to drop the other one's tables. The UI regression suite stubbed window.fetch, which is why its "all screens preserved" report had been green for the whole three weeks.

Readiness at Discovery: 1.4 out of 5. Data scored 1. So did testing.

Browser tab (Base44 UI) state arrays, seeded from one .js file patients[ ] appointments[ ] practitioners[ ] rooms[ ] waitlist[ ] close tab = all gone 3 weeks, 4 clinics, ~3,400 bookings 2 calls API POST /login GET /bootstrap -> demo template same JSON, every clinic, every time no write endpoint is ever called create_all at start-up Postgres 13 tables, correct columns 2 "migrations", both snapshots rows written in 3 weeks: 0 pytest conftest drop_all() on whatever DATABASE_URL is exported would wipe all 13 tables Red border = critical finding. UI regression gate stubbed fetch, so it stayed green for 3 weeks.
BEFORE: every booking lived in browser state arrays; the API's bootstrap call returned a demo template; the 13-table schema was rebuilt by create_all at start-up and never written to; the test suite would drop all of it on any DATABASE_URL.

What would have happened

What actually happens

Day 22: the laptop at clinic three freezes and gets restarted. Its bookings reset to the demo. Day 23: patients arrive for appointments that no longer exist at two sites; a practitioner is double-booked for the afternoon; the front desk goes back to a paper diary. Day 25: a patient complains about a missed procedure and the clinical director asks for the appointment record. There is none, and there never was. Day 30: the client's compliance lead asks how long booking records are retained. Medical appointment records must be kept for years in every European jurisdiction; the answer is "until someone presses F5". Day 45: the regulator's questionnaire arrives, and the agency's retainer, its reference, and its rebuild at its own cost all go on the same invoice. The four clinics run on paper for six weeks.

Forty-eight hours

Discovery had already told us the order. Nothing in the first six hours was about making the app better; it was about making it impossible to lose more.

Hour 0–6: stop anything from making it worse

First the test fence. Tests may touch a temporary SQLite file or a throwaway local Postgres whose name ends in _test, and nothing else. Any hosted hostname is refused before a single fixture runs. Then the schema: we froze the 13 tables into one explicit-DDL baseline migration, made create_all refuse to run outside development and test, and made the migration job the only thing allowed to own the schema.

# conftest.py, evaluated before any fixture
url = os.getenv("TEST_DATABASE_URL") or f"sqlite:///{tempfile.mkstemp(suffix='.db')[1]}"
p = urlparse(url)
if url.startswith("postgres") and (
    p.hostname not in {"localhost", "127.0.0.1"}
    or not p.path.endswith("_test")
):
    raise SystemExit("refusing non-disposable test database")
os.environ["DATABASE_URL"] = url   # force it; never setdefault

The guard is eight lines and you can paste it into your own conftest tonight. The last line matters: it overwrites the variable rather than filling it in when missing, so an exported staging URL in your shell can never leak into the suite.

"""0001_baseline: explicit DDL. Never imports live models. Forward-only."""
def upgrade():
    op.execute("""
      CREATE TABLE appointment (
        id          BIGSERIAL PRIMARY KEY,
        clinic_id   BIGINT NOT NULL,
        patient_id  BIGINT NOT NULL,
        starts_at   TIMESTAMPTZ NOT NULL,
        deposit_eur NUMERIC(14,2) NOT NULL DEFAULT 0
      )""")
def downgrade():
    raise RuntimeError("baseline is irreversible; rollback = restore backup")

A baseline migration is the CREATE TABLE statements written out by hand, not generated from whatever the models say today. It never changes again. Rollback of a baseline is a restore from backup, and saying so in the file stops someone trying to be clever at 2 a.m.

Config went fail-closed in the same window: an unset environment, a placeholder secret, or a SQLite URL in production now stops the process from starting, with the variable name in the log and never the value.

Hour 6–18: make the app remember

We wired every screen to real endpoints. The day view, the booking form, the rota and the waitlist each got a read and a write route, and the bootstrap call now returns the clinic's own rows. The demo template survived, but only behind a DEMO_SEED flag that production refuses to start with. The day view got keyset pagination so a busy Monday at the largest clinic does not load four months of history.

The three weeks of bookings were gone and we did not pretend otherwise. The team re-keyed the coming fourteen days from paper diaries and calendar invites in one afternoon, while the four sites stayed on the demo build in read-only mode. By hour 18 there were Playwright journeys for each role: receptionist books, practitioner sees the day, manager sees all four sites, and every one of them asserts on a row in the database rather than on a screen.

Hour 18–36: make wrong data impossible

This is the part a tool does not do for you. Eight runtime migrations added 42 foreign keys and 12 CHECK constraints: an appointment must point at a real patient, a real practitioner, a real room in the same clinic; an end time must be after a start time; a deposit cannot be negative. Money moved from a JavaScript float to NUMERIC(14,2).

ALTER TABLE appointment
  ADD CONSTRAINT fk_appointment_patient
  FOREIGN KEY (patient_id) REFERENCES patient(id) NOT VALID;
-- count the orphans, fix them, and only then:
ALTER TABLE appointment VALIDATE CONSTRAINT fk_appointment_patient;

CREATE TRIGGER audit_no_rewrite
  BEFORE UPDATE OR DELETE ON audit_log
  FOR EACH ROW EXECUTE FUNCTION refuse_change();

Adding a constraint as NOT VALID means new rows are checked immediately while old rows are left alone, so the migration cannot fail halfway on a live table. You then count the orphan rows, repair them, and validate once the count is zero. We validated all 42 at zero orphans; on your app, expect to find some.

The second half of the snippet is the audit table. Every booking, move, cancellation and no-show writes a row that says who, what, when and from which clinic. The trigger refuses any UPDATE or DELETE on that table, for everyone, including the application's own database user. That is what "append-only" means when the compliance lead asks.

Hour 36–48: make loss recoverable

Persisting the data was half the job. The other half is knowing, on a bad morning, how old the newest copy is and whether it restores.

# /etc/cron.d/clinic-db  (server time, root)
15 2 * * *  app  /srv/app/scripts/backup.sh         # pg_dump -Fc, encrypted, 30 kept
45 2 * * *  app  /srv/app/scripts/restore_drill.sh  # restore into scratch DB, count rows, alert

The first line takes the dump. The second restores it into a scratch database half an hour later, compares row counts with live and pages someone if they differ. A backup nobody has restored is a file, not a backup.

The same window added a /ready endpoint that checks the database, the migration head and the worker heartbeat; a CI pipeline that installs with hash-locked dependencies and runs alembic upgrade head && alembic check on every push; and a Compose deploy where the migrate job must finish before the web container is allowed to start. The suite went from 36 tests to 1,140, and every one of them runs against a database whose name ends in _test.

Engineering window. Discovery ran the night before; the clinics' own traffic came after. Stop the damage test fence, fail-closed config baseline migration Make it remember UI wired to real routes demo seed behind a flag Make wrong data impossible 42 FKs, 12 CHECKs, NUMERIC money append-only audit trigger Make loss recoverable nightly backup + restore drill /ready, CI, migrate-then-web 0-6 h 6-18 h 18-36 h 36-48 h 0 6 18 36 48 h rows persisted: 0 first real row written, hour 9 8 runtime migrations, 0 orphans tests 36 -> 1,140 Readiness 1.4 Readiness 3.7
TIMELINE: hours 0–6 fenced the tests and froze the schema; 6–18 wired the UI to real rows; 18–36 added 42 foreign keys, 12 CHECK constraints and the append-only audit trigger; 36–48 added nightly backups with a restore drill, /ready and CI.

From our desk

The first version of the test fence was rejected by our own security reviewer. It was a deny-list: refuse SQLite in production, refuse a short list of hosted database hostnames in tests. The reviewer's note was one line: a deny-list is a list of the mistakes you have already thought of. The version that shipped is an allow-list. Tests get localhost and a name ending in _test; production gets Postgres; everything else fails to start. Thirty-one review rejections like that came back on this job; each became a task before its story could close.

What it looks like now

The client's four clinics have been on the production-ready build for five weeks. The app looks the same to the receptionists, which was the point. What changed is underneath.

Browser (Base44 UI) no state survives a refresh and it does not need to API read + write route per screen /ready: DB, migration head Postgres 16 0001 baseline migration explicit DDL, forward-only, sole schema owner 42 FKs, 12 CHECKs, NUMERIC(14,2) added NOT VALID, validated at zero orphans audit_log, append-only trigger refuses UPDATE and DELETE Nightly backup 02:15, pg_dump -Fc encrypted, 30 kept Restore drill 02:45, scratch DB row counts must match pytest, 1,140 tests conftest guard runs first CI: migrate, check, audit localhost/clinic_test only DB tests may touch hosted hostnames refused Fence: no test process can reach the production database. Green border = verified by the restore drill or the CI gate.
AFTER: the API writes to Postgres owned by an explicit baseline migration, guarded by 42 foreign keys, 12 CHECK constraints and an append-only audit table; a nightly backup feeds a restore drill; tests are fenced to a local database whose name ends in _test.
Question the client can askBeforeAfter
Where is tomorrow's appointment list?In whichever tab has not been closedIn Postgres, with an audit row for every change
What happens on refresh?Reset to the demo day, 14 sample bookingsNothing. The browser holds no state worth keeping
How is the schema changed?create_all at every start-up; two snapshot "migrations" that would drop each other's tablesOne explicit baseline plus 8 forward-only migrations, run as a pre-deploy job
Can a test delete production data?Yes, on any exported DATABASE_URL, 13 tablesNo. Tests refuse anything but localhost and a name ending in _test
Can a deposit be negative or a room double-booked?Yes, floats and free text in an arrayNo. CHECK constraints and NUMERIC(14,2), enforced by the database
Who cancelled that appointment?UnknownAppend-only audit row: who, what, when, from which clinic
How old is the newest backup, and does it restore?No backup existedNightly, restore-tested at 02:45, row counts compared, someone paged if off
Readiness1.43.7

Two things stayed switched off, and the client knows it. Online self-booking by patients is built but disabled until the clinics decide the cancellation rules, and the SMS reminder route has no provider configured. Both ship as a config change plus a test flip when the answer arrives. That is what 3.7 means rather than 4.5.


Do this tonight

Do this tonight

1. The refresh test. Open your app, create something real, and note its name. Press F5. Then close the tab entirely, open a fresh one, and log in again. If it is not there, or if the list has gone back to sample names, you have what this agency had. Ten minutes.

2. Count the rows. Open your database console (on Base44 the Data tab; on Supabase the Table Editor; anywhere else psql) and run SELECT count(*) FROM appointments; or whatever your main table is. Use the app. Run it again. If the number does not move, the app is not writing. Five minutes.

3. Find the wipe. In the repository: grep -rn "drop_all\|create_all\|DATABASE_URL" tests/ conftest.py. Any hit that reads a URL from the environment without checking the hostname can empty whatever that URL points at. Fifteen minutes, including the fix.

Those three checks tell you whether the data exists. They do not tell you whether it is consistent, who changed it, or whether the backup you think you have will restore on the morning you need it. That is the other forty-seven hours.

The rule

The rule

If you cannot point at the row, you do not have the data. A screen that remembers is not a database, and a database nobody has restored is not a backup.

My client asked me where the appointments were stored. I opened the code to answer and realised I couldn't.— Agency owner

Send us what you have.

The readiness scan is free — install our MCP and your own coding agent runs it. When you want it fixed, send us what you have: fixed scope, fixed price, agreed before the clock starts. 48 hours later, it's production-ready — on your own cloud, with the tests to prove it.

Feed the machineWatch the 49-second film