Without notes, state yesterday’s main idea and one unresolved question.
Maintenance records and relational UI
Connecting the Full Stack
Objective
Display and modify child records tied to equipment.
This is system integration: individual modules may pass bench tests but the complete signal path must also be verified end to end.
- nested resource UX
- foreign-key relationships
- refreshing related views
Why this matters
Everything so far has been one table on one screen. Real applications are made of related things: a machine has a maintenance history, an order has line items, a patient has notes. Today you build the first relationship end to end, and you meet the failure that defines this kind of work — a workflow that is one action to the user but two writes to the database, where one of them can fail.
By the end, logging a repair will file the record under the right machine, change that machine's status, and leave both screens agreeing with each other.
Foreign keys: what actually links the two
A foreign key is a column in one table whose value must match an existing primary key in another
table. Here, maintenance_records.equipment_id holds the id of the equipment row it belongs to.
That column is the only thing linking the two. Not a shared name, not the order rows come back in,
not anything in your React code — one integer, checked by the database.
CREATE TABLE maintenance_records (
id SERIAL PRIMARY KEY,
equipment_id INTEGER NOT NULL REFERENCES equipment(id) ON DELETE RESTRICT,
performed_on DATE NOT NULL,
notes TEXT NOT NULL,
technician_id INTEGER NOT NULL REFERENCES users(id)
);
Two consequences worth stating out loud, because they are the constraint doing real work for you:
- Inserting a record whose
equipment_iddoes not exist fails. PostgreSQL raises error code23503, a foreign-key violation. You cannot create an orphan by accident. ON DELETE RESTRICTmeans deleting a machine that still has records fails too. The alternative,ON DELETE CASCADE, deletes the children with the parent. Both are legitimate; choose deliberately. For a maintenance log, silently destroying history is usually wrong.
The key lives on the many side. One machine has many records, so each record points up at one machine. This is the one-to-many shape from Day 43, and the direction catches people out: there is no list of record ids stored on the equipment row.
The serial number stamped on the board
When a control board is swapped out, the service tag records the chassis serial it came from. The
tag is not "near" the chassis or "filed at the same time" — it carries the chassis number, and
that number is what makes the history reconstructable years later. equipment_id is that stamped
serial, and REFERENCES equipment(id) is the rule that you cannot stamp a serial no chassis ever
had.
A patient chart
Every note goes into the folder with one patient number on it. Lose the number and the note is clinically worthless — not because the words changed, but because nobody can say who they are about.
Nested resources in the API and the UI
A maintenance record has no meaning on its own, so its URL says so:
GET /api/equipment/7/maintenance list the records for machine 7
POST /api/equipment/7/maintenance add a record to machine 7
The nested form makes the parent explicit and gives your Day 52 authorization one obvious place to stand: check that this user may see machine 7 once, before touching any records at all.
This produces the rule that matters most today. The parent id comes from the URL, never from the
request body. If your handler reads req.body.equipmentId, a user can post a record onto a machine
they were never allowed to open — the frontend would never do that, but the frontend is not what
sends the request. Take it from req.params.id and ignore any id in the body.
The UI mirrors the URL. The equipment detail page at /equipment/7 shows the machine at the top and
its history beneath, and the child list gets its own loading, empty, and error states. Empty is
the common case here: a machine installed last week legitimately has no maintenance. "No
maintenance recorded yet" and a spinner that never stops look identical if you only wrote one of
them.
One user action, two writes
"Log a repair and mark the machine back in service" is one thing to the technician. To the database
it is an INSERT into maintenance_records and an UPDATE to equipment.status.
If you send two requests, four outcomes exist, and two of them are wrong: the record saved but the status did not, or the status changed with no record explaining why. Both leave a maintenance log that lies, and the user has no idea which happened.
The fix is the transaction you met on Day 48. Do both writes inside one request, wrapped in BEGIN
and COMMIT, so either both land or neither does:
export async function logMaintenance(equipmentId, input) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`INSERT INTO maintenance_records (equipment_id, performed_on, notes, technician_id)
VALUES ($1, $2, $3, $4) RETURNING *`,
[equipmentId, input.performedOn, input.notes, input.technicianId]
);
const equipment = await client.query(
'UPDATE equipment SET status = $1 WHERE id = $2 RETURNING *',
[input.status, equipmentId]
);
await client.query('COMMIT');
return { record: rows[0], equipment: equipment.rows[0] };
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Note client.release() in finally. A connection you take from the pool and never give back is
gone; do that a few dozen times and the API stops answering with no error to explain it.
Refreshing the related views
After that write, two things on screen are stale: the record list and the machine's status badge. Refresh one and not the other and the page contradicts itself — a status of "active" above a list whose newest entry says the machine was stripped down.
You have two clean options:
- Return both objects from the endpoint, as the service above does, and set both pieces of state from one response. One round trip, and the two values are guaranteed to be from the same moment in time.
- Re-fetch both after success. Two requests, simpler code, and still consistent as long as you wait for both before rendering.
Either is fine. What is not fine is refreshing the child list and leaving the parent alone. And keep one more staleness in mind: the equipment list page, on a different route, is still holding the old status from before. Re-fetch it when the user navigates back rather than trusting state that was loaded minutes ago.
Watch the constraint refuse you
With the API running, try to insert a record for a machine that does not exist:
curl -i -X POST http://localhost:3000/api/equipment/99999/maintenance -H 'Content-Type: application/json' -d '{"performedOn":"2026-08-03","notes":"test","status":"active"}'
If the id is unknown you should get a 404 from your route check. Now remove that check
temporarily and try again: PostgreSQL raises 23503 and the insert fails anyway. Two independent
defences, and the database's one cannot be bypassed by a bug in your JavaScript.
Pair mode — before you write the code
"Help me map this UI workflow to API calls and database relationships before writing code." Give it the user's steps in plain words and make it produce the table below — screen action, HTTP request, tables touched, what must be true afterwards. Then write the code yourself and compare. Pair mode means you inspect every diff it suggests, run it, and can explain each line before keeping it.
| Screen action | Request | Tables | Must be true afterwards |
|---|---|---|---|
| Open machine 7 | GET /api/equipment/7 + GET /api/equipment/7/maintenance |
both, read | history shown, or an explicit empty state |
| Submit "log repair" | POST /api/equipment/7/maintenance |
both, one transaction | record exists and status changed, or neither |
| Return to the list | GET /api/equipment |
equipment, read | machine 7 shows its new status |
Walkthrough
The detail page reads its id from the route (Day 62) and loads parent and child together:
export function EquipmentDetailPage() {
const { id } = useParams();
const [equipment, setEquipment] = useState<Equipment | null>(null);
const [records, setRecords] = useState<MaintenanceRecord[]>([]);
const [status, setStatus] = useState<'loading' | 'error' | 'ready'>('loading');
const load = useCallback(async () => {
setStatus('loading');
try {
const [item, history] = await Promise.all([
apiFetch<Equipment>(`/api/equipment/${id}`),
apiFetch<MaintenanceRecord[]>(`/api/equipment/${id}/maintenance`)
]);
setEquipment(item);
setRecords(history);
setStatus('ready');
} catch {
setStatus('error');
}
}, [id]);
useEffect(() => { load(); }, [load]);
if (status === 'loading') return <p>Loading machine…</p>;
if (status === 'error' || !equipment) return <p role="alert">Could not load this machine.</p>;
return (
<>
<h2>{equipment.name} — {equipment.status}</h2>
{records.length === 0
? <p>No maintenance recorded yet.</p>
: <ul>{records.map((r) => <li key={r.id}>{r.performedOn}: {r.notes}</li>)}</ul>}
<LogMaintenanceForm equipmentId={equipment.id} onSaved={load} />
</>
);
}
onSaved={load} is the refresh: after a successful submit the form calls the same loader, so parent
and child are both re-read and neither can drift.
Your turn
- Confirm your
maintenance_recordstable has aREFERENCES equipment(id)foreign key. Add it if it is missing, as a migration (Day 48) rather than by editing the original file. - Add
GET /api/equipment/:id/maintenance, ordered newest first, scoped to that machine only. - Add
POST /api/equipment/:id/maintenancethat takesequipment_idfromreq.paramsand ignores any id in the body. Return404if the machine does not exist. - Do the insert and the status update in one transaction. Return both saved objects.
- Build the detail page: machine header, history list with a real empty state, and the log form.
- On success, refresh both. Confirm the status badge and the newest record agree.
- Reload the page and confirm the record is still there under the same machine.
- Verify in SQL that it went to the right place:
SELECT equipment_id, notes FROM maintenance_records ORDER BY id DESC LIMIT 1;
You are done when
A record you created appears under the correct machine after a reload, the machine's status
reflects the same action, and you can point at the equipment_id column in psql and say that
this foreign key — not anything in your React code — is what ties them together.
Common pitfalls
- Taking the parent id from the request body. It lets a client file a record against a machine it may not touch. The URL is the authority.
- Two separate requests for one action. Sooner or later the second one fails and the log is wrong. One request, one transaction.
- No empty state on the child list. A machine with no history looks broken instead of new.
- Refreshing only the list. The status badge above it goes stale and the page contradicts itself in front of the user.
Verify it yourself
Open today's reference, the PostgreSQL tutorial, and find its section on foreign keys.
- This lesson used
ON DELETE RESTRICT. Find what PostgreSQL does by default when noON DELETEclause is given, and note whether that matches what you assumed. - The tutorial describes at least one referential action this lesson did not mention. Name it and say what it would do to your maintenance history.
Write both answers into your project notes, then check which behaviour your own schema actually has. Assumptions about constraints are exactly the kind of thing that stays wrong until data is lost.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Add maintenance history, create record, and equipment status update as one user workflow.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
Maintenance records persist and appear under the correct equipment.
Working with AI today
Define one small task, review the plan, inspect the diff, run checks, and explain every changed section.
Help me map this UI workflow to API calls and database relationships before writing code.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.